鴻蒙應用做到後面,真正讓人頭痛的,往往不是某個頁面寫得醜,也不是某個按鈕樣式沒調好,而是入口越來越多以後,啟動邏輯開始亂。
桌面圖示能進來,服務卡片能進來,通知能進來,Deep Link 能進來,應用內部還可能用 Want 拉起另一個 UIAbility。第一版程式碼一般都挺樸素:在首頁 aboutToAppear 裡讀一下參數,判斷要不要跳詳情頁。剛開始沒問題,甚至看起來挺清爽。
但需求一多,首頁就很容易變成「入口垃圾桶」。
這裡判斷通知來源,那裡判斷服務卡片參數,後面又補一個外部連結解析。冷啟動的時候還能勉強,二次拉起、回前台、橫豎屏切換、任務堆疊恢復一來,問題就開始變得有點玄學了。
我之前見過一個挺典型的線上問題:使用者從服務卡片點進來,本來應該打開某個訂單詳情頁,結果偶爾落到首頁;使用者從外部連結再次拉起應用,頁面沒刷新;還有更隱蔽的,應用已經在背景了,新的 Want 進來以後,全域初始化又跑了一遍,監聽註冊了兩次,後面同一個事件回調兩遍。
查日誌的時候也挺難受。每個頁面都覺得自己只是「順手處理一下入口參數」,最後誰也說不清這次啟動到底是桌面啟動、卡片啟動,還是二次拉起。
這種問題不能靠再加幾個 if 硬頂。Stage 模型下,AbilityStage、Want、UIAbility 這條鏈路本來就應該承擔啟動治理的職責。只是很多專案寫著寫著,把它們當成了「系統自動產生的模板檔案」,真正的業務入口反而全塞到頁面裡了。

單獨看這幾個概念,其實都不複雜。
AbilityStage 是 Module 級別的元件管理器,HAP 首次載入時會建立它。UIAbility 是帶畫面的應用元件,負責建立、銷毀、前後台切換這些生命週期。Want 是元件之間傳遞資訊的載體,啟動目標、參數、action、uri 這些東西都可以從裡面拿。
但工程裡真正容易出問題的地方,不是「某個回呼怎麼寫」,而是邊界沒劃清。
我一般會這麼分:
這幾條邊界看著有點囉嗦,但真到專案裡很有用。
後面新增通知入口、服務卡片入口、Deep Link 入口時,不需要每個頁面跟著改。入口邏輯集中在入口層,頁面只關心「我要展示什麼業務狀態」。這才比較像一個能長期維護的結構。
很多啟動混亂,根源就是頁面直接讀 Want。
頁面一旦開始知道太多入口細節,就會慢慢變成半個路由中心。今天讀 scene,明天讀 from,後天再補一個 uri,最後首頁裡一堆參數判斷,誰也不敢動。
我更習慣定義一個中間結構,叫 LaunchPayload。它不追求把 Want 的所有欄位都複製一遍,只留下業務真正需要的東西。
ts 體驗AI代碼助手 程式碼解讀複製程式碼// common/launch/LaunchPayload.ets
export enum LaunchScene {
NORMAL = 'normal',
CARD = 'card',
NOTIFICATION = 'notification',
DEEP_LINK = 'deep_link',
INTERNAL = 'internal'
}
export interface LaunchPayload {
scene: LaunchScene
targetPage: string
bizId?: string
uri?: string
from?: string
rawAction?: string
extras: Record<string, string>
receivedAt: number
}
這裡有個小取捨:extras 我只放字串。
不是說 Want 裡不能帶別的型別,而是啟動參數最好別變成一個「萬能物件」。入口傳來的東西越雜,頁面兜底越麻煩。真要複雜物件,建議傳 id,再讓業務層去查詳情。
啟動參數要負責的是「把使用者帶到哪」,不是「把整個業務現場都搬進來」。這句話挺重要,很多入口混亂都是從這裡開始的。
下面這個 LaunchPayloadParser 就是專門幹髒活的。
它負責把不同來源的 Want 參數,統一整理成業務可讀的結構。頁面拿到的不是一坨原始參數,而是一份已經歸一過的啟動載荷。
ts 體驗AI代碼助手 程式碼解讀複製程式碼// common/launch/LaunchPayloadParser.ets
import { Want } from '@kit.AbilityKit'
import { LaunchPayload, LaunchScene } from './LaunchPayload'
export class LaunchPayloadParser {
static parse(want: Want | undefined): LaunchPayload {
const params = want?.parameters ?? {}
const uri = want?.uri ?? ''
const action = want?.action ?? ''
const scene = this.parseScene(params, uri, action)
const bizId = this.readString(params, 'bizId')
const from = this.readString(params, 'from')
return {
scene,
targetPage: this.resolveTargetPage(scene, bizId, uri),
bizId,
uri,
from,
rawAction: action,
extras: this.pickSafeExtras(params),
receivedAt: Date.now()
}
}
private static parseScene(params: Record<string, Object>, uri: string, action: string): LaunchScene {
const scene = this.readString(params, 'scene')
if (scene === 'card') {
return LaunchScene.CARD
}
if (scene === 'notification') {
return LaunchScene.NOTIFICATION
}
if (uri.length > 0) {
return LaunchScene.DEEP_LINK
}
if (action.length > 0) {
return LaunchScene.INTERNAL
}
return LaunchScene.NORMAL
}
private static resolveTargetPage(scene: LaunchScene, bizId?: string, uri?: string): string {
if (scene === LaunchScene.DEEP_LINK && uri) {
return this.resolveDeepLink(uri)
}
if (bizId && bizId.length > 0) {
return 'pages/Detail'
}
return 'pages/Home'
}
private static resolveDeepLink(uri: string): string {
// 這裡只做簡單範例。
// 真實專案裡建議做白名單解析,別讓外部 uri 任意指定頁面路徑。
if (uri.includes('/detail')) {
return 'pages/Detail'
}
if (uri.includes('/search')) {
return 'pages/Search'
}
return 'pages/Home'
}
private static pickSafeExtras(params: Record<string, Object>): Record<string, string> {
const allowList: string[] = ['tab', 'keyword', 'source']
const extras: Record<string, string> = {}
allowList.forEach((key: string) => {
const value = this.readString(params, key)
if (value !== undefined) {
extras[key] = value
}
})
return extras
}
private static readString(params: Record<string, Object>, key: string): string | undefined {
const value = params[key]
return typeof value === 'string' ? value : undefined
}
}
這段程式碼看起來確實有點囉嗦,但它救命的地方也就在這。
所有入口先過一層白名單,不允許外部參數直接控制內部頁面路徑;所有參數先轉成可控結構,不讓頁面到處寫 want.parameters?.xxx。專案越大,這種「看起來多一層」的程式碼越值錢。
我自己比較怕那種「先湊合一下」的入口程式碼。因為入口一旦散了,後面不是不好重構,是沒人敢重構。使用者從哪裡進來、帶了什麼參數、應該落到哪個頁面,全都藏在幾個頁面生命週期裡,查一次問題能把人查麻。
AbilityStage 很容易被誤用。
有些專案會把一堆業務初始化都丟進去:資料庫、網路、打點、使用者資訊、遠端設定,全塞上。冷啟動一慢,大家又開始懷疑系統回呼慢,或者懷疑首屏效能不行。其實很多時候,是自己把太重的東西放錯地方了。
我的建議比較保守:AbilityStage 只做輕量、必要、進程級的事情。
比如日誌初始化、依賴容器準備、Specified 啟動模式的 key 分流。需要 IO、需要使用者態、需要網路的初始化,不要一股腦壓在這裡。
ts 體驗AI代碼助手 程式碼解讀複製程式碼// entry/src/main/ets/entryability/MyAbilityStage.ets
import { AbilityStage, Want } from '@kit.AbilityKit'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { LaunchPayloadParser } from '../common/launch/LaunchPayloadParser'
export default class MyAbilityStage extends AbilityStage {
onCreate(): void {
// 這裡適合做輕量級、進程級準備。
// 不建議在這裡做耗時網路請求,也別依賴頁面上下文。
hilog.info(0x0000, 'AppStage', 'AbilityStage onCreate')
}
onAcceptWant(want: Want): string {
// Specified 啟動模式下,系統會透過這個 key 決定復用哪個 UIAbility 實例。
// key 設計要穩定,不要把時間戳這種隨機值塞進去。
const payload = LaunchPayloadParser.parse(want)
if (payload.bizId && payload.bizId.length > 0) {
return `detail_${payload.bizId}`
}
return 'main'
}
}
onAcceptWant 這塊很容易寫錯。
我見過有人為了「保證每次都是新的」,直接回傳時間戳。短期看,好像解決了頁面不刷新的問題;長期看,其實是把實例復用搞亂了。
Specified 模式要的不是「每次都新開」,而是「同一類業務復用同一個目標實例」。key 的粒度要跟業務場景一致。比如詳情頁按 id 分流,主入口統一回到 main。別為了省事,把 key 寫成一個隨機數,那後面任務堆疊和實例管理都會跟著亂。
UIAbility 的生命週期更貼近業務。
使用者冷啟動時會走 onCreate,視窗建立時走 onWindowStageCreate;應用已有實例再次被拉起時,常見場景會走 onNewWant。如果只在 onCreate 裡處理參數,二次拉起就很容易漏。
我一般會在 UIAbility 裡保留一個目前啟動載荷,然後用 LocalStorage 注入頁面。頁面不直接碰 Want。
ts 體驗AI代碼助手 程式碼解讀複製程式碼// entry/src/main/ets/entryability/EntryAbility.ets
import { AbilityConstant, UIAbility, Want } from '@kit.AbilityKit'
import { window } from '@kit.ArkUI'
import { hilog } from '@kit.PerformanceAnalysisKit'
import { LaunchPayload } from '../common/launch/LaunchPayload'
import { LaunchPayloadParser } from '../common/launch/LaunchPayloadParser'
export default class EntryAbility extends UIAbility {
private storage: LocalStorage = new LocalStorage()
private latestPayload?: LaunchPayload
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
this.latestPayload = LaunchPayloadParser.parse(want)
this.storage.setOrCreate('launchPayload', this.latestPayload)
hilog.info(0x0000, 'EntryAbility',
`onCreate scene=${this.latestPayload.scene}, target=${this.latestPayload.targetPage}`)
}
onWindowStageCreate(windowStage: window.WindowStage): void {
// 頁面首次載入時,把歸一後的啟動載荷注入進去。
windowStage.loadContent('pages/Home', this.storage, (err) => {
if (err.code) {
hilog.error(0x0000, 'EntryAbility',
`loadContent failed, code=${err.code}, message=${err.message}`)
return
}
hilog.info(0x0000, 'EntryAbility', 'loadContent success')
})
}
onNewWant(want: Want, launchParam: AbilityConstant.LaunchParam): void {
// 已有實例被再次拉起時,不要重複跑全域初始化。
// 這裡只更新啟動載荷,讓頁面或路由層消費。
this.latestPayload = LaunchPayloadParser.parse(want)
this.storage.setOrCreate('launchPayload', this.latestPayload)
hilog.info(0x0000, 'EntryAbility',
`onNewWant scene=${this.latestPayload.scene}, target=${this.latestPayload.targetPage}`)
}
onForeground(): void {
// 恢復輕量資源,例如刷新目前會話狀態。
// 不建議在這裡重複解析啟動參數。
hilog.info(0x0000, 'EntryAbility', 'onForeground')
}
onBackground(): void {
// 暫停耗時任務、保存必要狀態。
hilog.info(0x0000, 'EntryAbility', 'onBackground')
}
onDestroy(): void {
// 取消監聽、釋放 UIAbility 級資源。
hilog.info(0x0000, 'EntryAbility', 'onDestroy')
}
}
這段的重點不是 loadContent,而是 onCreate 和 onNewWant 共用同一個解析器。
冷啟動和二次拉起不應該分裂成兩套業務規則。你今天忘了在 onNewWant 補一個參數,明天就會遇到那種特別煩的現象:應用殺掉後正常,背景喚起異常;從桌面進來正常,從通知進來異常。
這類問題通常不好測,因為測試同學一旦把應用殺掉重進,問題就消失了。
頁面裡可以用 @LocalStorageProp 拿到啟動載荷。至於它來自桌面、卡片還是 Deep Link,頁面不需要知道太多。
ts 體驗AI代碼助手 程式碼解讀複製程式碼// entry/src/main/ets/pages/Home.ets
import { LaunchPayload, LaunchScene } from '../common/launch/LaunchPayload'
@Entry
@Component
struct Home {
@LocalStorageProp('launchPayload') launchPayload?: LaunchPayload
@State tip: string = '正常進入首頁'
aboutToAppear(): void {
this.consumeLaunchPayload(this.launchPayload)
}
onPageShow(): void {
// 從背景回到前台時,頁面可做輕量刷新。
// 不建議在這裡重新猜測啟動來源。
}
private consumeLaunchPayload(payload?: LaunchPayload): void {
if (!payload) {
return
}
if (payload.scene === LaunchScene.CARD) {
this.tip = `從服務卡片進入,業務ID:${payload.bizId ?? '無'}`
return
}
if (payload.scene === LaunchScene.DEEP_LINK) {
this.tip = `從外部連結進入:${payload.uri ?? ''}`
return
}
if (payload.bizId) {
this.tip = `準備打開詳情:${payload.bizId}`
return
}
this.tip = '正常進入首頁'
}
build() {
Column({ space: 16 }) {
Text('HarmonyOS 啟動治理範例')
.fontSize(22)
.fontWeight(FontWeight.Bold)
Text(this.tip)
.fontSize(15)
.fontColor('#666666')
if (this.launchPayload?.targetPage === 'pages/Detail') {
Button('進入詳情')
.onClick(() => {
// 專案裡可以交給統一 RouterService,
// 不建議在每個頁面散落路由拼接。
})
}
}
.padding(24)
.width('100%')
}
}
這裡有個問題經常有人問:為什麼不在 UIAbility 裡直接路由到詳情頁?
可以,但要看專案的路由方案。
如果你的登入態、彈窗恢復、頁面堆疊管理、Tab 狀態都在頁面層或者 RouterService 裡,UIAbility 直接跳詳情頁,有時候反而會繞過業務狀態。我的做法是,UIAbility 負責把「啟動意圖」送到頁面,真正的業務路由交給應用內部的 RouterService。
這樣頁面堆疊歸頁面,入口歸入口,邊界比較清楚。後面要改路由策略,也不用去生命週期回呼裡翻一堆程式碼。
UIAbility 啟動到前台時,會觸發 onCreate()、onWindowStageCreate()、onForeground() 這一類生命週期回呼。文件順序看懂不難,難的是每個回呼裡該放什麼、不該放什麼。

我通常按下面這個口徑拆:
onCreate:讀取 Want,生成 LaunchPayload,準備 UIAbility 級狀態。別在這裡直接操作還沒建立的視窗。
onWindowStageCreate:載入頁面,注入 LocalStorage,綁定視窗相關邏輯。視窗級別的東西放這裡,不要提前。
onNewWant:已有實例再次被拉起時更新啟動意圖。這裡不要重複初始化全域服務,也不要重複註冊監聽。
onForeground:應用回到前台,恢復輕量資源,比如刷新會話、恢復播放按鈕狀態。不要把它當第二個 onCreate。
onBackground:暫停耗時任務,保存必要狀態。能停的就停,尤其是輪詢、定位、長連線這類邏輯。
onDestroy:取消監聽、釋放資源、打點收尾。不要假設它每次都一定按你期望的時機觸發,但該寫的清理還是要寫。
職責分清以後,很多「偶發問題」就不再玄學了。
比如二次拉起沒刷新,就去看 onNewWant;前後台切換重複初始化,就查 onForeground;視窗相關狀態異常,就去看 onWindowStageCreate。至少排查方向是明確的,不至於在首頁、詳情頁、路由工具類之間來回翻。
首頁讀 Want、首頁解析 URI、首頁判斷通知、首頁處理卡片參數,短期確實快,長期基本一定亂。首頁是 UI,不是入口閘道。
這個坑很多專案都會踩,因為第一版最方便的地方就是首頁。但方便不是沒有代價,只是代價晚點來。
這類 bug 很煩:冷啟動正常,背景再次拉起異常;殺掉應用再試,又正常了。
原因往往是只在 onCreate 解析了 Want,已有實例二次拉起時沒有更新業務載荷。開發自測時如果習慣每次都殺進程,很容易漏掉。
onAcceptWant 回傳的 key 應該穩定、有業務含義。
隨機 key 會讓實例復用不可控;key 粒度太粗,會導致不同業務入口搶同一個實例。這個地方別偷懶,最好一開始就按業務場景定規則。
Deep Link 或外部 Want 裡帶一個 path,然後你直接 router 到對應頁面,這個寫法很危險。
至少要做白名單對映。外部參數只能表達意圖,不能拿到內部路由的完全控制權。尤其是對外開放的連結入口,更不能相信傳進來的每一個欄位。
onForeground 不是重啟。
回前台時做輕量恢復可以,別把登入初始化、資料庫初始化、全域監聽註冊再跑一遍。重複初始化這種問題前期不明顯,後面會變成重複請求、重複回調、狀態錯亂。
啟動之後經常伴隨非同步動作,比如查詳情、拉設定、驗證登入。頁面銷毀後回呼還更新狀態,就會出現偶現閃跳或者日誌報錯。
建議給非同步任務加 taskId,或者在頁面消失時取消。別讓舊任務回來覆蓋新頁面。
如果啟動入口多,建議給每次 LaunchPayload 分配一個自增序號。後到的啟動意圖優先級更高,舊任務回來不能覆蓋新狀態。
這個設計不複雜,但很管用。
ts 體驗AI代碼助手 程式碼解讀複製程式碼// common/launch/LaunchSession.ets
import { LaunchPayload } from './LaunchPayload'
export class LaunchSession {
private currentSeq: number = 0
private latest?: LaunchPayload
next(payload: LaunchPayload): number {
this.currentSeq += 1
this.latest = payload
return this.currentSeq
}
isLatest(seq: number): boolean {
return seq === this.currentSeq
}
getLatest(): LaunchPayload | undefined {
return this.latest
}
}
頁面或 RouterService 使用時:
ts 體驗AI代碼助手 程式碼解讀複製程式碼const seq = launchSession.next(payload)
this.loadBizData(payload).then(() => {
if (!launchSession.isLatest(seq)) {
// 舊入口觸發的非同步結果,不允許覆蓋新入口狀態。
return
}
// 更新頁面或路由狀態
})
使用者從通知點進來,半秒後又從服務卡片點進來,兩次入口都可能觸發非同步載入。沒有序號保護,舊請求後回來就能把新頁面狀態蓋掉。
很多「偶爾跳錯詳情」的問題,本質就是舊任務覆蓋了新任務。這個問題不加日誌很難看出來,加了任務號以後,一眼就能看出是誰回來晚了。
這套啟動治理不是所有 demo 都需要。一個只有首頁和設定頁的小工具,沒必要一上來就搞一堆入口層封裝。
但下面幾類應用,我建議早點做:
只要入口超過兩個,就建議盡早把 Want 解析收斂掉。別等首頁堆到幾百行再重構,到那時候你已經分不清哪段邏輯是給哪個入口補的了。
HarmonyOS 的 AbilityStage、Want、UIAbility,不只是應用模板裡那幾個預設檔案。它們更像應用的入口骨架。
骨架穩了,頁面和業務路由才不會到處補洞。
我的習慣是:AbilityStage 只做輕量進程級準備和分流;Want 進入應用後馬上轉成 LaunchPayload;UIAbility 統一處理冷啟動和二次拉起;頁面只消費歸一後的業務參數。
看著多了幾個類,但後面加入口、查問題、做灰度、做打點, 都會輕鬆很多。
別把啟動參數散落在每個頁面裡。頁面一多,誰都不願意碰;入口一多,問題就開始像玄學。啟動鏈路這種東西,越早工程化,越不容易在上線後給自己挖坑。