HarmonyOS AbilityStage 實戰:別把啟動參數散落在每個頁面裡

鴻蒙應用做到後面,真正讓人頭痛的,往往不是某個頁面寫得醜,也不是某個按鈕樣式沒調好,而是入口越來越多以後,啟動邏輯開始亂。

桌面圖示能進來,服務卡片能進來,通知能進來,Deep Link 能進來,應用內部還可能用 Want 拉起另一個 UIAbility。第一版程式碼一般都挺樸素:在首頁 aboutToAppear 裡讀一下參數,判斷要不要跳詳情頁。剛開始沒問題,甚至看起來挺清爽。

但需求一多,首頁就很容易變成「入口垃圾桶」。

這裡判斷通知來源,那裡判斷服務卡片參數,後面又補一個外部連結解析。冷啟動的時候還能勉強,二次拉起、回前台、橫豎屏切換、任務堆疊恢復一來,問題就開始變得有點玄學了。

我之前見過一個挺典型的線上問題:使用者從服務卡片點進來,本來應該打開某個訂單詳情頁,結果偶爾落到首頁;使用者從外部連結再次拉起應用,頁面沒刷新;還有更隱蔽的,應用已經在背景了,新的 Want 進來以後,全域初始化又跑了一遍,監聽註冊了兩次,後面同一個事件回調兩遍。

查日誌的時候也挺難受。每個頁面都覺得自己只是「順手處理一下入口參數」,最後誰也說不清這次啟動到底是桌面啟動、卡片啟動,還是二次拉起。

這種問題不能靠再加幾個 if 硬頂。Stage 模型下,AbilityStage、Want、UIAbility 這條鏈路本來就應該承擔啟動治理的職責。只是很多專案寫著寫著,把它們當成了「系統自動產生的模板檔案」,真正的業務入口反而全塞到頁面裡了。

image.png

AbilityStage / Want / UIAbility,別分開看

單獨看這幾個概念,其實都不複雜。

AbilityStage 是 Module 級別的元件管理器,HAP 首次載入時會建立它。UIAbility 是帶畫面的應用元件,負責建立、銷毀、前後台切換這些生命週期。Want 是元件之間傳遞資訊的載體,啟動目標、參數、action、uri 這些東西都可以從裡面拿。

但工程裡真正容易出問題的地方,不是「某個回呼怎麼寫」,而是邊界沒劃清。

我一般會這麼分:

  • AbilityStage 管進程級、模組級的東西,比如輕量初始化、Specified 啟動模式分流、全域依賴準備。
  • Want 只當入口資訊,進來以後儘快轉成業務能理解的結構。
  • UIAbility 管視窗、生命週期和啟動載荷注入。
  • ArkUI 頁面只消費歸一後的業務參數,不直接解析原始 Want。

這幾條邊界看著有點囉嗦,但真到專案裡很有用。

後面新增通知入口、服務卡片入口、Deep Link 入口時,不需要每個頁面跟著改。入口邏輯集中在入口層,頁面只關心「我要展示什麼業務狀態」。這才比較像一個能長期維護的結構。

先把原始 Want 收斂成 LaunchPayload

很多啟動混亂,根源就是頁面直接讀 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,再讓業務層去查詳情。

啟動參數要負責的是「把使用者帶到哪」,不是「把整個業務現場都搬進來」。這句話挺重要,很多入口混亂都是從這裡開始的。

寫一個 Want 解析器,別讓頁面自己猜

下面這個 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 很容易被誤用。

有些專案會把一堆業務初始化都丟進去:資料庫、網路、打點、使用者資訊、遠端設定,全塞上。冷啟動一慢,大家又開始懷疑系統回呼慢,或者懷疑首屏效能不行。其實很多時候,是自己把太重的東西放錯地方了。

我的建議比較保守: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:冷啟動和二次拉起要走同一套邏輯

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,而是 onCreateonNewWant 共用同一個解析器。

冷啟動和二次拉起不應該分裂成兩套業務規則。你今天忘了在 onNewWant 補一個參數,明天就會遇到那種特別煩的現象:應用殺掉後正常,背景喚起異常;從桌面進來正常,從通知進來異常。

這類問題通常不好測,因為測試同學一旦把應用殺掉重進,問題就消失了。

頁面只消費 LaunchPayload,不碰原始 Want

頁面裡可以用 @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() 這一類生命週期回呼。文件順序看懂不難,難的是每個回呼裡該放什麼、不該放什麼。

image.png

我通常按下面這個口徑拆:

onCreate:讀取 Want,生成 LaunchPayload,準備 UIAbility 級狀態。別在這裡直接操作還沒建立的視窗。

onWindowStageCreate:載入頁面,注入 LocalStorage,綁定視窗相關邏輯。視窗級別的東西放這裡,不要提前。

onNewWant:已有實例再次被拉起時更新啟動意圖。這裡不要重複初始化全域服務,也不要重複註冊監聽。

onForeground:應用回到前台,恢復輕量資源,比如刷新會話、恢復播放按鈕狀態。不要把它當第二個 onCreate

onBackground:暫停耗時任務,保存必要狀態。能停的就停,尤其是輪詢、定位、長連線這類邏輯。

onDestroy:取消監聽、釋放資源、打點收尾。不要假設它每次都一定按你期望的時機觸發,但該寫的清理還是要寫。

職責分清以後,很多「偶發問題」就不再玄學了。

比如二次拉起沒刷新,就去看 onNewWant;前後台切換重複初始化,就查 onForeground;視窗相關狀態異常,就去看 onWindowStageCreate。至少排查方向是明確的,不至於在首頁、詳情頁、路由工具類之間來回翻。

常見坑位:這些地方真的容易埋雷

1. 首頁承擔了太多入口職責

首頁讀 Want、首頁解析 URI、首頁判斷通知、首頁處理卡片參數,短期確實快,長期基本一定亂。首頁是 UI,不是入口閘道。

這個坑很多專案都會踩,因為第一版最方便的地方就是首頁。但方便不是沒有代價,只是代價晚點來。

2. onNewWant 忘了處理

這類 bug 很煩:冷啟動正常,背景再次拉起異常;殺掉應用再試,又正常了。

原因往往是只在 onCreate 解析了 Want,已有實例二次拉起時沒有更新業務載荷。開發自測時如果習慣每次都殺進程,很容易漏掉。

3. Specified key 設計太隨意

onAcceptWant 回傳的 key 應該穩定、有業務含義。

隨機 key 會讓實例復用不可控;key 粒度太粗,會導致不同業務入口搶同一個實例。這個地方別偷懶,最好一開始就按業務場景定規則。

4. 外部參數直接控制頁面路徑

Deep Link 或外部 Want 裡帶一個 path,然後你直接 router 到對應頁面,這個寫法很危險。

至少要做白名單對映。外部參數只能表達意圖,不能拿到內部路由的完全控制權。尤其是對外開放的連結入口,更不能相信傳進來的每一個欄位。

5. 前後台切換重複初始化

onForeground 不是重啟。

回前台時做輕量恢復可以,別把登入初始化、資料庫初始化、全域監聽註冊再跑一遍。重複初始化這種問題前期不明顯,後面會變成重複請求、重複回調、狀態錯亂。

6. 頁面銷毀後,非同步回呼還在改狀態

啟動之後經常伴隨非同步動作,比如查詳情、拉設定、驗證登入。頁面銷毀後回呼還更新狀態,就會出現偶現閃跳或者日誌報錯。

建議給非同步任務加 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 都需要。一個只有首頁和設定頁的小工具,沒必要一上來就搞一堆入口層封裝。

但下面幾類應用,我建議早點做:

  • 內容類應用:從通知、搜尋、外部連結進入文章或影片詳情。
  • 辦公類應用:服務卡片進入審批、待辦、日程詳情。
  • 電商和本地生活:活動連結、訂單通知、桌面捷徑入口都要落到不同業務頁。
  • 工具類應用:從分享、檔案打開、Deep Link 進入不同編輯模式。
  • 多 UIAbility 應用:主介面、獨立編輯器、沉浸式展示頁需要不同啟動實例策略。

只要入口超過兩個,就建議盡早把 Want 解析收斂掉。別等首頁堆到幾百行再重構,到那時候你已經分不清哪段邏輯是給哪個入口補的了。

結尾:入口治理寫早一點,後面少還很多債

HarmonyOS 的 AbilityStage、Want、UIAbility,不只是應用模板裡那幾個預設檔案。它們更像應用的入口骨架。

骨架穩了,頁面和業務路由才不會到處補洞。

我的習慣是:AbilityStage 只做輕量進程級準備和分流;Want 進入應用後馬上轉成 LaunchPayload;UIAbility 統一處理冷啟動和二次拉起;頁面只消費歸一後的業務參數。

看著多了幾個類,但後面加入口、查問題、做灰度、做打點, 都會輕鬆很多。

別把啟動參數散落在每個頁面裡。頁面一多,誰都不願意碰;入口一多,問題就開始像玄學。啟動鏈路這種東西,越早工程化,越不容易在上線後給自己挖坑。


原文出處:https://juejin.cn/post/7635866179564126242


精選技術文章翻譯,幫助開發者持續吸收新知。

共有 0 則留言


精選技術文章翻譯,幫助開發者持續吸收新知。
🏆 本月排行榜
🥇
站長阿川
📝13   💬7  
273
🥈
我愛JS
💬1  
4
🥉
Gigi
2
評分標準:發文×10 + 留言×3 + 獲讚×5 + 點讚×1 + 瀏覽數÷10
本數據每小時更新一次
📢 贊助商廣告 · 我要刊登