與踩坑實踐)
去年做設備端業(yè)務和技術調研時我把團隊里已有的 Flutter 代碼庫嘗試往 OpenHarmony 生態(tài)遷移第一個拿來練手的就是生活助手類 App 里的喝水提醒模塊每天到了設定時間彈一條通知用戶點一下記錄喝了多少水。聽起來就是一個 Timer 加一個通知真開始做才發(fā)現(xiàn)這個功能橫跨了 Dart 層的狀態(tài)管理、鴻蒙原生通知與提醒代理、本地存儲、混合棧頁面生命周期而且每一步都跟 Android/iOS 上的習慣不太一樣。這篇文章就把我的完整實現(xiàn)思路和踩坑記錄整理出來適合想在 OpenHarmony 上復用 Flutter 代碼、或者準備做鴻蒙端生活助手類應用的開發(fā)者參考。1. 為什么在 OpenHarmony 上用 Flutter 做喝水提醒1.1 功能需求拆解喝水提醒聽上去簡單實際拆開看有四個獨立的能力點:定時調度、系統(tǒng)通知、數(shù)據(jù)記錄、狀態(tài)展示。定時調度決定什么時候觸發(fā)提醒通知決定提醒怎么觸達用戶數(shù)據(jù)記錄負責把每次喝水的時間、杯數(shù)存下來狀態(tài)展示則是首頁上的今日進度、下次喝水倒計時這些 UI。任何一個環(huán)節(jié)都不能省而且它們之間的依賴關系是串行的:調度觸發(fā)通知通知被點擊后寫記錄記錄最終驅動 UI 刷新。還有一個隱性需求:提醒要盡量可靠。很多同類 App 只在應用開著的時候用 Timer 彈對話框App 一旦被殺就徹底失聲了。對于喝水這種高頻剛需場景用戶要的是系統(tǒng)級的、App 不在前臺也能收到的提醒這就必須借助鴻蒙原生的提醒代理能力而不能只靠 Flutter 側的前臺計時器。1.2 為什么選擇 Flutter 而不是純 ArkTS 開發(fā)我在技術選型時反復權衡過純 ArkTS 開發(fā)方案。OpenHarmony 的聲明式 UI 框架這幾年迭代很快寫一個簡單頁面完全不虛但如果團隊已有成熟的 Flutter 業(yè)務代碼純 ArkTS 意味著所有業(yè)務邏輯、UI 組件、狀態(tài)管理方案全部重寫這個成本在項目啟動階段幾乎不可接受。Flutter 的優(yōu)勢在于 UI 層完全自繪不依賴系統(tǒng)組件樹。這意味著頁面渲染邏輯在 OpenHarmony 上的行為和 Android 上幾乎一致遷移時主要改的是平臺通道和原生插件層而不是 Dart 業(yè)務代碼。對喝水提醒這個模塊來說真正涉及鴻蒙原生的地方只有通知調度和提醒代理其他全部是純 Dart 邏輯所以用 Flutter 做業(yè)務層、ArkTS 做宿主殼是最短路徑。1.3 混合棧架構的整體設計OpenHarmony 的 HAP 應用必須有一個原生殼入口Flutter 在里面只是頁面渲染引擎這跟安卓原生項目嵌入 Flutter 頁面的模式本質上是一樣的只是宿主從 Android 工程換成了鴻蒙工程。我的架構分成三層:宿主層:鴻蒙側的 MainAbility 負責應用生命周期同時持有 Flutter 容器用于渲染首頁、統(tǒng)計頁等 Flutter 頁面。橋接層:MethodChannel 負責 Flutter 調用鴻蒙原生能力比如發(fā)布通知、注冊提醒EventChannel 負責鴻蒙原生把通知點擊事件、提醒觸發(fā)結果回傳 Flutter。業(yè)務層:純 Dart 實現(xiàn)配置管理、記錄存儲、倒計時狀態(tài)、UI 刷新。選擇 MethodChannel EventChannel 組合的原因很直接:通知的發(fā)布是典型的單向調用適合 MethodChannel提醒被點擊、提醒觸發(fā)回調是持續(xù)產(chǎn)生的事件流EventChannel 更合適。如果只用一個 MethodChannel 做輪詢不僅費電而且很難做到實時性。2. 環(huán)境準備與工程接入2.1 版本選型是第一個坑Flutter 官方主線目前并不直接支持 OpenHarmony需要拉取 OpenHarmony SIG 維護的 Flutter 分支。這個分支從官方 Flutter 倉庫 fork 出來持續(xù)跟進上游版本同時維護了鴻蒙側的引擎適配和插件適配。我當時沒有太在意版本匹配直接拉了一個較新的 Flutter 分支結果運行flutter doctor時立刻觸發(fā)了一直被提到的警告:The current configured Flutter SDK is not known to be fully supported. Please ...這個警告的本質是:本地 Flutter SDK 版本比當前工程模板期望支持的版本要新工具鏈認為存在未知的兼容性風險。網(wǎng)上很多人遇到這個提示就直接忽略了但我在實際調試中發(fā)現(xiàn)版本跨度大的時候真的會出現(xiàn)一些詭異的編譯報錯問題往往很難查。我的建議是查看 SIG 分支的倉庫說明確認它當前跟蹤的是官方 Flutter 哪個穩(wěn)定版本然后讓本地分支跟工程模板保持同一基準。2.2 創(chuàng)建鴻蒙工程并接入 Flutter 模塊創(chuàng)建工程的流程不算復雜但順序很重要。先在 DevEco Studio 里新建一個 Empty Ability 工程作為宿主殼拿到包名和工程結構之后再在這個工程里集成 Flutter 模塊。這里要注意不要在鴻蒙工程里直接flutter create因為 OpenHarmony 分支生成的模塊結構跟普通 Flutter 工程不完全一樣最好按 SIG 倉庫的指引操作。接入的核心邏輯是:Flutter 代碼構建出鴻蒙側可加載的產(chǎn)物宿主殼啟動時把 Flutter 頁面掛載到指定的 Ability 上。你可以理解為鴻蒙的 HAP 是一個瀏覽器殼Flutter 是里面的頁面引擎兩者通過 build 階段生成的中間產(chǎn)物完成綁定。由于不同分支的產(chǎn)物格式有差異這一步我強烈建議直接照抄 SIG 倉庫 README 里的配置不要自己發(fā)揮。我當時就是因為少配了一個依賴項導致運行時一直報找不到 So 庫排查了兩天才意識到是產(chǎn)物沒打進 HAP。2.3 構建配置中的兩個高頻報錯構建階段最常見的 Gralde 報錯是:You are applying Flutters main Gradle plugin imperatively using the apply script method, which is not supported. Remove this apply statement ...這個報錯出現(xiàn)在使用新版 Flutter Gradle 插件的工程里。舊版 Flutter 工程習慣在android/settings.gradle里用apply from的方式加載 Flutter 工具腳本新版插件要求改用pluginsDSL 聲明式加載兩者機制完全不同。我當時的解決方式是把settings.gradle里的apply from拿掉改成:plugins { id com.android.application id dev.flutter.flutter-gradle-plugin }改完記得執(zhí)行一次 Gradle Sync并且讓本地的 Flutter SDK 路徑通過local.properties里的flutter.sdk明確指定。這個報錯在鴻蒙分支上同樣會出現(xiàn)因為鴻蒙 Flutter 分支沿用同一套構建體系排查思路完全一致。3. 喝水提醒核心功能實現(xiàn)3.1 數(shù)據(jù)模型與本地存儲設計先把數(shù)據(jù)層定下來。喝水提醒要存兩類數(shù)據(jù):一類是用戶配置包括每日目標杯數(shù)、每杯容量、提醒間隔另一類是每天的飲水記錄包含喝水時間戳和杯數(shù)。配置我用 SharedPreferences 存記錄我選擇以 JSON 文件方式存在應用私有目錄這樣后續(xù)做歷史統(tǒng)計時更容易擴展也不會被輕量存儲的 key-value 限制束縛。Dart 側的記錄模型很簡單:class WaterRecord { final DateTime time; final int cupVolumeMl; WaterRecord({required this.time, required this.cupVolumeMl}); MapString, dynamic toJson() { time: time.toIso8601String(), cupVolumeMl: cupVolumeMl, }; factory WaterRecord.fromJson(MapString, dynamic json) WaterRecord( time: DateTime.parse(json[time] as String), cupVolumeMl: json[cupVolumeMl] as int, ); }配置類同樣用簡單模型承載。這里有個小技巧:把目標的dailyGoal用杯數(shù)而不是毫升數(shù)存儲避免用戶修改杯容量時歷史目標跟著漂移。例如目標 8 杯、每杯 200ml今天喝到 1200ml如果改成 250ml 每杯按毫升算就變成 4.8 杯了按杯數(shù)算則始終穩(wěn)定。這類細節(jié)點在生活類 App 里很影響用戶體驗。3.2 雙重提醒調度策略提醒調度是整個模塊的核心我最終采用了前臺計時器和系統(tǒng)級提醒代理并行的方案。前臺計時器負責應用存活期間的即時反饋。我用Timer.periodic每 30 秒檢查一次當前時間是否命中提醒窗口命中就彈應用內對話框同時刷新首頁倒計時。不用固定時間的Timer是因為用戶可能隨時修改提醒間隔或關閉某段時間的提醒輪詢方式對配置變更的響應最及時。Timer? _timer; void startReminderLoop() { _timer?.cancel(); _timer Timer.periodic(const Duration(seconds: 30), (timer) { final now DateTime.now(); if (_shouldNotifyNow(now) _checkCooldown(now)) { _triggerReminder(); } }); }_shouldNotifyNow里判斷當前時間距上次喝水是否超過設定的間隔_checkCooldown保證同一時段不會重復轟炸用戶。這兩個判斷很關鍵不然用戶午休睡個覺起來手機上可能堆了十幾條提醒。系統(tǒng)級提醒用的是鴻蒙的提醒代理服務。這部分必須走原生側編碼因為應用進程被清理后 Dart 代碼就停了只有系統(tǒng)級的提醒代理能保證觸達。我在鴻蒙側通過reminderAgentManager注冊一個定時提醒設置觸發(fā)時間和點擊后要跳轉的 Ability。這里要注意把提醒的wantAgent指向自己的應用否則點擊通知后無法正確喚起頁面。3.3 MethodChannel 與 EventChannel 橋接原生能力Flutter 側通過 MethodChannel 調起鴻蒙原生提醒代理。通道名我用ohos_water_app/reminder方法名scheduleReminder參數(shù)帶上標題、內容和觸發(fā)時間戳:static const _reminderChannel MethodChannel(ohos_water_app/reminder); Futurevoid scheduleSystemReminder({ required String title, required String content, required DateTime triggerTime, }) async { await _reminderChannel.invokeMethod(scheduleReminder, { title: title, content: content, triggerTimestamp: triggerTime.millisecondsSinceEpoch, }); }鴻蒙側對應實現(xiàn)時我用了ohos.reminderAgentManager:import reminderAgentManager from ohos.reminderAgentManager; export function scheduleReminder(title: string, content: string, triggerTime: number): void { const reminder { reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_TIMER, triggerTimeInSeconds: Math.floor((triggerTime - Date.now()) / 1000), actionItems: [ { title: 喝水, type: 0 } ], wantAgent: { pkgName: com.example.waterapp, abilityName: MainAbility } }; reminderAgentManager.publishReminder(reminder) .then((id: number) { console.info(reminder published: ${id}); }) .catch((err: Error) { console.error(publish reminder failed: ${err.message}); }); }triggerTimeInSeconds是按秒計算的相對時間不是絕對時間戳我一開始傳了絕對時間戳導致提醒提前了不知多少倍觸發(fā)調試時差點以為是鴻蒙系統(tǒng) bug。這個轉換關系在文檔里其實有寫但很容易被忽略。通知點擊事件的回傳我用了 EventChannel。鴻蒙側在用戶點擊通知的wantAgent回調里把信息通過 eventSink 發(fā)給 FlutterDart 側用receiveBroadcastStream監(jiān)聽:static const _clickChannel EventChannel(ohos_water_app/notification_click); void listenNotificationClick() { _clickChannel.receiveBroadcastStream().listen((event) { // event 里帶通知 id、點擊時間等 _onNotificationClicked(event); }, onError: (error) { // 通道斷開時處理 }); }這個設計讓日志記錄和統(tǒng)計變得非常順暢。用戶點了提醒通知原生側把事件推回 DartDart 側直接寫一條喝水記錄并刷新首頁整個過程沒有額外的輪詢成本。3.4 首頁交互與頁面狀態(tài)保持首頁 UI 我分了三塊:今日進度環(huán)形圖、下次喝水倒計時、歷史記錄入口。狀態(tài)管理用的是 ValueNotifier 加自定義組合沒有引重量級狀態(tài)管理框架因為喝水提醒的共享狀態(tài)并不多ValueNotifier 足夠且直觀。有一個坑必須重點說:底部Tab切換到統(tǒng)計頁再切回來首頁的倒計時不走了。這個問題正是熱詞里討論的flutter navigator切換頁面后會丟失狀態(tài)嗎。默認情況下 Navigator 的 push/pop 會銷毀下層路由狀態(tài)不走常規(guī)保存邏輯倒計時 Timer 自然就停了。解決辦法是給首頁加AutomaticKeepAliveClientMixin并且把底部 Tab 切換改成IndexedStack而不是反復 push 新路由。IndexedStack 會同時保持所有子頁面的狀態(tài)配合 keepAlive 才能真正做到切換不丟計時器:class HomePage extends StatefulWidget { override StateHomePage createState() _HomePageState(); } class _HomePageState extends StateHomePage with AutomaticKeepAliveClientMixin { override bool get wantKeepAlive true; // build 方法中不要忘記 super.build(context) }這個操作雖然只是加一個 Mixin但它直接決定了提醒功能在真實使用中可不可靠。如果用戶切一下頁面就把計時器丟了這個功能等于殘廢。3.5 記錄統(tǒng)計與連續(xù)天數(shù)歷史記錄我做了兩個維度:按天聚合的總杯數(shù)和一周趨勢。每天的數(shù)據(jù)從 JSON 文件里讀取按日期分組后渲染。連續(xù)喝水天數(shù)這個指標稍微處理了一下:用戶可能凌晨喝完就直接跨天了所以我在判斷連續(xù)天數(shù)時允許當天還沒結束時就算作連續(xù)中否則用戶會看到連續(xù)天數(shù)偶爾倒退。統(tǒng)計頁還順手處理了 TabBar 的默認點擊動畫。熱詞里有人在找flutter tabbar點擊取消動畫效果我個人偏好切換更干脆的手感所以把 TabBar 的animationDuration設成了零同時監(jiān)聽了 TabController 的 index 變化來同步頁面內容而不是依賴默認的動畫感知。這種細節(jié)在生活類工具里很影響主觀手感實際調試時值得花點時間。4. 常見問題與排查技巧實錄4.1 Flutter SDK 兼容性警告問題現(xiàn)象是開頭提到的The current configured Flutter SDK is not known to be fully supported警告。它本身不影響編譯但會讓人心慌而且確實可能在后續(xù)構建中暴露 API 差異。處理思路是三步走:第一步查看項目模板要求的具體 Flutter 版本一般在pubspec.yaml或工程配置里能發(fā)現(xiàn)痕跡。第二步檢查當前flutter --version輸出如果兩者差距過大把 Flutter 分支切換到與模板配套的版本。第三步跑一次flutter doctor -v確認 OpenHarmony 工具鏈識別正常再看警告是否消失。我最后發(fā)現(xiàn)是工程模板太舊升級模板依賴后就干凈了。4.2 構建時的 AssertionError 與緩存問題熱詞里有人遇到過flutter打包 java.lang.assertionerror: java.lang.exception: could not close i...這類錯誤。這個報錯經(jīng)常讓人誤以為是代碼問題其實絕大多數(shù)時候是 Gradle 緩存或文件句柄異常。我的排查順序是:先執(zhí)行flutter clean和flutter pub get清除 Dart 側的緩存。再刪除鴻蒙工程下的build目錄和 Gradle 緩存目錄注意別刪錯了。執(zhí)行./gradlew --stop干掉后臺 Gradle 守護進程釋放占用的文件鎖。重新構建。如果重建還是失敗重點檢查工程路徑是否包含中文或空格以及資源文件里有沒有文件名帶特殊字符的圖片。這類問題在 Windows 和部分 CI 環(huán)境特別常見項目路徑稍微復雜一點就會引發(fā)資源讀取異常。4.3 Navigator 狀態(tài)丟失問題深入排查前面說的AutomaticKeepAliveClientMixin是主解法但實際還有更隱秘的場景:用戶在統(tǒng)計頁停留很久系統(tǒng)因為內存壓力回收了底層頁面結果切回首頁發(fā)現(xiàn)狀態(tài)還是丟了。這時只靠 keepAlive 不夠還需要在頁面initState里判斷是否有緩存的數(shù)據(jù)并重建狀態(tài)。我的做法是在首頁 State 里把倒計時目標和下次提醒時間持久化到內存緩存didChangeAppLifecycleState里監(jiān)聽應用回到前臺事件如果發(fā)現(xiàn)計時器已經(jīng)停止就主動重新啟動。同時把喝水量、目標等關鍵數(shù)據(jù)在dispose前寫入本地配置這樣即使頁面真的被銷毀恢復到前臺時也能無縫還原。4.4 第三方 Flutter 插件鴻蒙適配流程喝水提醒本身沒有用太多第三方插件但我在這個項目里順帶摸清了給平臺插件補鴻蒙適配的完整流程這個經(jīng)驗對后續(xù)接入登錄、推送、地圖等插件非常有用。以某個提供原生能力的平臺插件為例適配鴻蒙的步驟是先在插件工程中新增鴻蒙原生實現(xiàn)新建繼承自平臺通道接口的類把 Android/iOS 原生方法重寫為鴻蒙 API 調用然后在插件的注冊入口把實現(xiàn)類掛上去最后在pubspec.yaml里聲明鴻蒙側的支持。流程本身不復雜難點在于大部分第三方插件的鴻蒙原生實現(xiàn)需要自己寫工作量和插件的復雜度成正比。所以在選型階段就要考察插件的社區(qū)維護情況盡量選那些已經(jīng)在公開倉庫里提供ohos目錄或harmonyos適配的版本而不是自己從零補全。4.5 常見問題速查表現(xiàn)象根因解決方案Flutter SDK 版本警告本地 SDK 與模板要求版本不一致對齊分支版本升級模板依賴Gradle 提示 apply 方式不支持新版插件要求 plugins DSL改用 plugins 塊聲明打包 AssertionErrorGradle 緩存損壞或文件句柄異常clean、刪 build、停 Gradle 守護進程切 Tab 后計時器停路由狀態(tài)被銷毀IndexedStack KeepAlive提醒時間提前triggerTimeInSeconds 誤傳絕對時間戳改成相對秒數(shù)后臺無法提醒僅靠前臺 TimerApp 被殺后失效使用 reminderAgentManager 系統(tǒng)提醒4.6 關于通知權限和用戶可見性最后提一個容易被忽略的細節(jié):鴻蒙系統(tǒng)的通知權限。用戶在系統(tǒng)設置里關掉通知后無論 Flutter 側還是提醒代理側都無法觸達用戶所以 App 首次啟動時要主動引導用戶開啟通知權限。我的做法是進入設置頁時檢查通知權限狀態(tài)如果未授權彈一個引導對話框說明喝水提醒的場景然后調用鴻蒙側打開設置授權頁面。這個引導文案不能寫得太單薄要說明提醒用于記錄每日飲水目標不會產(chǎn)生廣告類打擾授權率會明顯高一些。寫在最后就我個人而言在 OpenHarmony 上做 Flutter 開發(fā)最大的感受是功能本身不難難在版本組合和工具鏈的匹配。只要 Flutter 分支、鴻蒙 SDK、Gradle 插件三者版本對不上各種莫名其妙的問題就會接踵而至。如果你準備走這條路我的建議是先照著 SIG 倉庫的 Release 分支配好一套可用組合鎖定版本后不要再隨意升級等業(yè)務跑通后再考慮版本滾動。喝水提醒雖小但把 Flutter 與 OpenHarmony 之間的橋接路徑完整走通了:MethodChannel 調用原生能力EventChannel 回傳實時事件本地存儲管理業(yè)務數(shù)據(jù)混合棧頁面處理好生命周期。這個能力組合可以直接復用到番茄鐘、吃藥提醒、久坐提醒等一整套生活助手功能上后續(xù)要做的只是把調度層和通知層抽成公共模塊讓不同業(yè)務各自注冊提醒。希望這篇文章能幫你少踩幾個坑尤其在版本對齊和狀態(tài)保持上真的值得多花一點心思。