:從零打造日歷家庭相冊 App)
年初家里聚會翻手機(jī)相冊找去年除夕的合影翻了幾百張也沒翻到長輩們都在旁邊等著。那一刻我就下了決心要給家里做一個“按日子看照片”的家庭相冊 App。正好手頭有一臺 OpenHarmony 設(shè)備又想驗證一下 Flutter 在這個新系統(tǒng)上的落地情況于是就有了這個 flutter_for_openharmony 家庭相冊 App 實戰(zhàn)項目。這篇文章把整個實現(xiàn)過程拆開講清楚重點放在日歷 Tab 的完整實現(xiàn)鏈路上從環(huán)境搭建、狀態(tài)管理、媒體庫讀取到真機(jī)調(diào)試適合正在做 Flutter 多端適配、或者在 OpenHarmony 上找 UI 技術(shù)選型方案的人參考。1. 項目背景與總體架構(gòu)設(shè)計1.1 為什么是 Flutter for OpenHarmony家里人的設(shè)備很雜有人用 OpenHarmony 的平板有人用安卓手機(jī)我自己主力是 iPhone。真要給全家做一個相冊最理想的形態(tài)是一套 UI 代碼跑在多個平臺上。Flutter 在這里的優(yōu)勢非常明顯UI 層完全自繪不依賴系統(tǒng)原生控件只要目標(biāo)設(shè)備的 Flutter 引擎適配到位頁面的表現(xiàn)就能保持一致。OpenHarmony 這邊經(jīng)過社區(qū)和廠商的持續(xù)適配已經(jīng)具備跑 Flutter 應(yīng)用的基礎(chǔ)能力所以我直接把項目定位成 Flutter for OpenHarmony而不是給 OpenHarmony 單獨用 ArkTS 重寫一套。就“ArkTS 和 Flutter 誰更流行”這類討論我的觀點一向是別單純比流行度要比你手里有什么。如果你團(tuán)隊成員全是 Flutter 背景公司里已經(jīng)積累了一堆 Flutter 組件和工程化經(jīng)驗?zāi)窃?OpenHarmony 設(shè)備上繼續(xù)用 Flutter 明顯劃算。反過來如果項目只面向 OpenHarmony 一種設(shè)備團(tuán)隊又更熟悉 TS 生態(tài)那 ArkTS 當(dāng)然也是合理選擇。技術(shù)選型沒有標(biāo)準(zhǔn)答案只有適不適合當(dāng)前團(tuán)隊和場景。1.2 家庭相冊的功能邊界與主頁面劃分這個 App 的定位很明確不做社交不做云同步先做好一件事讓照片按時間組織打開日歷點哪一天就能看到那一天的所有照片。所以主界面我采用了底部三 Tab 的結(jié)構(gòu)相冊 Tab按日期分組展示所有照片滑動列表快速瀏覽日歷 Tab月視圖日歷有照片的日期顯示縮略圖角標(biāo)點擊某一天進(jìn)入當(dāng)天的照片網(wǎng)格相機(jī) Tab直接打開相機(jī)拍照拍完自動歸到當(dāng)天日期下。日歷 Tab 是核心入口因為“回憶”這個東西天然帶時間屬性。比起傳統(tǒng)相冊里按文件夾或全部時間線倒序排列以日歷為入口的瀏覽方式更有“翻舊賬”的儀式感也符合家人找照片的直覺先想到某年某月某日發(fā)生過什么再去找那天的照片。1.3 分層方案數(shù)據(jù)層、狀態(tài)層、UI 層這個項目我沒有用很重的架構(gòu)但分層還是做了不然寫到一半必然亂。數(shù)據(jù)層services負(fù)責(zé)調(diào)用 OpenHarmony 媒體庫能力讀取照片原始數(shù)據(jù)和縮略圖把結(jié)果封裝成模型返回狀態(tài)層providers用 Provider 管理全局共享狀態(tài)比如當(dāng)前選中日期、照片分組數(shù)據(jù)、加載狀態(tài)UI 層pages/widgets只負(fù)責(zé)展示和交互事件上報不直接碰媒體庫 API。這樣分層以后日歷 Tab 和相冊 Tab 之間只需要共享同一個 Provider 實例日歷改了日期相冊網(wǎng)格自動刷新完全不用在頁面構(gòu)造函數(shù)里層層傳參。這也是 Flutter 組件通信最常見也最穩(wěn)妥的做法。2. 搭建 Flutter for OpenHarmony 開發(fā)環(huán)境2.1 工具鏈與 SDK 準(zhǔn)備OpenHarmony 開發(fā)環(huán)境比傳統(tǒng) Android 要多裝幾樣?xùn)|西我把關(guān)鍵點列一下DevEco StudioOpenHarmony 應(yīng)用開發(fā)的主力 IDE版本建議用 4.0 以上它會幫你在安裝時配置 OpenHarmony SDK、HDC 工具和 ohpm 包管理器HDCOpenHarmony Device Connector相當(dāng)于 Android 的 adb負(fù)責(zé)連接真機(jī)、安裝 HAP 包、抓日志Java JDK要求 JDK 17版本不對會在編譯期報各種奇怪的錯誤Flutter SDK必須使用適配 OpenHarmony 的 flutter_flutter 分支不能用官方原版 Flutter SDK這一點最容易踩坑。我當(dāng)時用的是 openharmony-sig 維護(hù)的 flutter_flutter 3.7 適配分支配 OpenHarmony 4.0 Release。如果你現(xiàn)在拿到的是更新的版本原理都一樣只是 API 號有變化。2.2 從零初始化一個可用于 OpenHarmony 的 Flutter 工程初始化工程這一步最簡單的方式還是在 DevEco Studio 里直接新建工程再在工程目錄下把 Flutter 相關(guān)的目錄結(jié)構(gòu)補(bǔ)上。但我的操作習(xí)慣是用命令行創(chuàng)建更可控flutter create --org com.example --project-name family_album --platforms ohos .這里的--platforms ohos是關(guān)鍵它會額外生成ohos目錄里面是 OpenHarmony 殼工程。如果沒有加這個參數(shù)生成出來的只有 android、ios 這些平臺目錄OpenHarmony 設(shè)備無從跑起。生成完成后還需要在ohos工程里配置 SDK 路徑和簽名信息。對于社區(qū)調(diào)試可以先不配置正式簽名但需要給ohos模塊配置自動簽名否則 HAP 裝不進(jìn)真機(jī)。2.3 首次跑真機(jī)最常見的啟動失敗排查“flutter 新建項目后跑不起來”這個問題我在這套環(huán)境里踩了個遍一個排查表來整理最有效現(xiàn)象常見原因處理方法運行時報找不到 OpenHarmony SDKlocal.properties 里的 sdk.dir 沒配置檢查并寫入 sdk 實際路徑Gradle 同步失敗、版本沖突DevEco 自帶 Gradle 與 Flutter 插件要求不一致統(tǒng)一 Gradle 版本關(guān)閉離線模式重新拉取真機(jī)連接后 HDC 顯示離線USB 調(diào)試模式未打開或驅(qū)動問題打開開發(fā)者模式用hdc list targets確認(rèn)識別HAP 安裝失敗報簽名錯誤未配置調(diào)試簽名在 DevEco 中配置自動簽名后重新構(gòu)建編譯通過但啟動白屏Flutter 引擎未正確打包進(jìn) HAP檢查 flutter.embedding 相關(guān)依賴是否完整如果你的 Flutter 引擎是源碼方式編譯的首次構(gòu)建會比較久后面增量構(gòu)建會快很多。我當(dāng)時遇到最狡猾的一個坑是環(huán)境變量里的 JAVA_HOME 指向了低版本 JDKDevEco 自己內(nèi)部用新版本沒問題但命令行構(gòu)建時用的卻是舊 JDK報錯信息很不直觀浪費了大半天。3. 日歷 Tab 實現(xiàn)的完整鏈路從日期數(shù)據(jù)到照片分組3.1 日歷展示方案選型自研還是依賴組件日歷控件是我在整個項目里糾結(jié)最久的模塊。網(wǎng)上有現(xiàn)成的日歷組件包也看到有人參考 uview 里“日歷直接展示”的做法就是整月平鋪、點擊切換日期。uview 那個交互思路本身很直觀一個網(wǎng)格展示整月日期頂部左右箭頭切換月份點某一天觸發(fā)列表刷新。但問題在于第三方日歷組件往往為了通用性做了很多抽象業(yè)務(wù)上需要“日期有照片就顯示角標(biāo)”這種需求反而要花不少功夫去看源碼改造升級依賴時還容易出問題。我的選擇是自研一個輕量日歷網(wǎng)格原因很簡單方案優(yōu)點缺點第三方日歷包功能齊全、好看定制角標(biāo)困難、維護(hù)風(fēng)險、包體積大自研 GridView 日歷業(yè)務(wù)耦合低、完全可控、體積小需要寫周末/月切換邏輯自研一個單月網(wǎng)格的代碼量大概 100 行出頭主要邏輯就是算出當(dāng)月第一天是星期幾然后按42 6周×7列生成日期格子遇到空位就填充空白占位。實測下來很穩(wěn)后面要加農(nóng)歷、節(jié)日都是在自己代碼里加不用去適配別人封裝好的接口。3.2 相冊數(shù)據(jù)按日期聚合的模型設(shè)計要讓日歷知道“哪幾天有照片”首先要把媒體庫里的照片按日期分組。這一步的關(guān)鍵是日期鍵的設(shè)計。我定義了一個很小的模型class PhotoDayModel { final String dateKey; // 20250123精確到天 final int count; // 當(dāng)天照片數(shù) final String coverPath; // 當(dāng)天第一張照片路徑作為縮略圖 } class AlbumProvider extends ChangeNotifier { ListPhotoDayModel _days []; String selectedDateKey ; ListPhotoDayModel get days _days; void setSelectedDate(String key) { selectedDateKey key; notifyListeners(); } }dateKey的生成不要用日期字符串格式化直接拼有坑。比如某張照片的時間戳是凌晨 00:30如果按本地時區(qū)轉(zhuǎn)成YYYY-MM-DD可能還是今天但如果你把時間戳除以 86400000 再取整會因為時區(qū)偏移產(chǎn)生前后一天的偏差。我統(tǒng)一用本地時區(qū)做日期歸一化先把時間戳轉(zhuǎn)成DateTime再取year/month/day拼接這樣分組結(jié)果才和日歷格子嚴(yán)格對應(yīng)。3.3 日歷點擊與相冊網(wǎng)格之間的 Provider 聯(lián)動日歷 Tab 和相冊網(wǎng)格的聯(lián)動是這個項目里 Flutter 組件通信最核心的部分。我在日歷 Tab 的點擊事件里只做一件事調(diào)context.readAlbumProvider().setSelectedDate(dateKey)。這里重點說一下context.read和context.watch的區(qū)別新手很容易搞混。context.watch會注冊監(jiān)聽Provider 變化時當(dāng)前 widget 會重建context.read只是讀取狀態(tài)并調(diào)用方法不產(chǎn)生訂閱。日歷格子點擊時用context.read因為格子本身不需要因為日期變化而重建它只要把事件傳出去就行。而相冊網(wǎng)格部分用ConsumerAlbumProvider包裹Provider 一旦通知網(wǎng)格就自動獲取新的分組數(shù)據(jù)刷新。這種分工是 Provider 用得比較正宗的方式既不用手動傳回調(diào)也不會因為一個日期變化把整個頁面全部重建一遍。3.4 日歷 Tab 中的月切換與性能優(yōu)化月切換我采用PageView.builder來做每一頁是一個日歷月初始定位到當(dāng)前月份左右滑動切換。這里有個經(jīng)驗PageView默認(rèn)會預(yù)加載相鄰頁面如果每頁都觸發(fā)一次昂貴的照片分組邏輯滑動時會明顯卡頓。所以我做了兩層優(yōu)化第一月份頁面緩存。只緩存當(dāng)前頁和左右兩頁避免無限預(yù)加載。第二照片分組邏輯放進(jìn) Isolate。媒體庫照片多時按日期分組的純計算任務(wù)耗時可能達(dá)到幾百毫秒在主 Isolate 里會卡 UI。用compute把媒體庫原始數(shù)據(jù)發(fā)送到后臺 Isolate在那里完成分組回來直接渲染網(wǎng)格。這個改動對低端設(shè)備尤其有效滑動日歷和滑動相冊都能保持流暢。4. 相冊瀏覽與相機(jī)模塊讓日歷里的每一天都能回得去4.1 媒體庫讀取、權(quán)限申請與圖片緩存策略O(shè)penHarmony 上讀取相冊和 Android 不太一樣權(quán)限方面需要在ohos模塊的module.json5里聲明媒體庫權(quán)限{ requestPermissions: [ { name: ohos.permission.READ_IMAGEVIDEO }, { name: ohos.permission.WRITE_IMAGEVIDEO } ] }然后在 App 啟動后向用戶申請授權(quán)。這里有一個細(xì)節(jié)如果只做瀏覽可以只申請讀取權(quán)限拍照并保存才需要寫權(quán)限。權(quán)限聲明寧少勿多多了應(yīng)用商店審核和應(yīng)用啟動彈窗都會比較煩人。讀取照片數(shù)據(jù)時媒體庫返回的是資源對象包含 URI 和修改時間等。我把它統(tǒng)一封裝到一個MediaLibraryService里上層完全不感知當(dāng)前是 OpenHarmony 還是安卓。照片在相冊網(wǎng)格里展示時千萬不要直接用原圖路徑加載必須走縮略圖接口。一張 12MP 的原圖解碼后內(nèi)存占用輕松超過 40MB一個網(wǎng)格 9 張圖直接爆內(nèi)存。我的做法是請求指定尺寸的縮略圖服務(wù)器端或系統(tǒng)提供的縮略圖接口會按需生成App 側(cè)再把縮略圖做一層磁盤緩存重復(fù)滑動時基本無感。4.2 相機(jī)接入在日歷中直接記錄當(dāng)下瞬間相機(jī)模塊原本計劃用 Flutter 的 camera 插件但 OpenHarmony 上插件適配并不總是那么及時。我的策略是先跑通一個最簡單的拍照閉環(huán)再做擴(kuò)展。具體做法是先嘗試用image_picker拉起系統(tǒng)相機(jī)如果這個插件在當(dāng)前 OpenHarmony 版本上能正常工作就直接用代碼量和維護(hù)成本最低。如果拉起失敗走備選方案寫一個MethodChannel在 OpenHarmony 原生側(cè)用 CameraKit 實現(xiàn)拍照拍完把圖片路徑通過 channel 傳回 Flutter 側(cè)。平臺通道調(diào)用其實不神秘Flutter 側(cè)就三行代碼final path await MethodChannel(family_album/camera) .invokeMethodString(takePhoto);原生側(cè)在ohos工程的 Ability 里對應(yīng)處理takePhoto返回圖片路徑。拿到路徑后把照片插入媒體庫再刷新 Provider 的數(shù)據(jù)列表當(dāng)天日歷格子上立刻出現(xiàn)新照片角標(biāo)。這一條鏈路走通整個相冊 App 就有了“記錄當(dāng)下”的能力拍完就能在日歷里看到。4.3 沒有照片的日期怎么處理日歷這個東西很誠實有的日子有照片有的日子是空白。剛開始我打算空白日期直接置灰不可點后來被家人吐槽說“想看看那天發(fā)生了什么都沒辦法”。于是改了一下交互空白日期也可以點擊相冊網(wǎng)格區(qū)域顯示一個占位狀態(tài)文案大概是“這一天還沒有留下照片去拍一張吧”旁邊放一個拍照按鈕。這個交互改動不大但對使用體驗的提升很明顯。它把“沒有照片”從功能性缺陷轉(zhuǎn)變成一種行動引導(dǎo)也讓日歷始終保持在主控地位。做這個功能時我把空狀態(tài)也放進(jìn)了相冊 Tab 的邏輯里兩個 Tab 共用一套狀態(tài)判斷避免出現(xiàn)“日歷能點進(jìn)去但相冊里一片空白”這種割裂感。5. 真機(jī)調(diào)試、渲染與性能優(yōu)化5.1 HDC 連接與日志查看技巧OpenHarmony 真機(jī)調(diào)試我全程用命令行 HDC比 IDE 里的圖形化面板效率高很多。核心就幾條命令hdc list targets # 查看當(dāng)前連接設(shè)備 hdc shell hilog | grep flutter # 實時過濾 Flutter 相關(guān)日志 hdc install xxx.hap # 安裝包Flutter 應(yīng)用在 OpenHarmony 上跑的時候Flutter 引擎自身的日志和 Dart 側(cè)debugPrint輸出都會進(jìn) hilog。過濾flutter關(guān)鍵詞基本能看到生命周期、Dart 異常和引擎錯誤。如果在日志里看到DartError但堆棧不全我習(xí)慣在 Dart 側(cè)加一個全局FlutterError.onError鉤子把異常信息打點輸出配合日志分析定位快很多。5.2 圖片滑動列表卡頓與內(nèi)存控制相冊網(wǎng)格滑動卡頓是這類 App 的經(jīng)典問題。第一版我直接把所有當(dāng)天照片的原圖縮略圖路徑交給Image.network類似的方式加載結(jié)果真機(jī)上一滑就掉幀后面壓了三個優(yōu)化網(wǎng)格項外層包RepaintBoundary避免格子滾動時觸發(fā)兄弟節(jié)點的重繪只請求 200x200 尺寸的縮略圖內(nèi)存占用直接下降一個量級列表使用SliverGrid 懶加載每次只構(gòu)建可視區(qū)域內(nèi)的格子。還有一個容易被忽視的點Flutter 的Image組件默認(rèn)沒有磁盤緩存只有內(nèi)存緩存。所以我把媒體庫的縮略圖路徑做了一層簡單的磁盤緩存映射第二次加載同一張圖時走緩存速度提升非常明顯。5.3 Impeller 渲染引擎與中文渲染相關(guān)注意我跑這個項目時Flutter 引擎已經(jīng)默認(rèn)啟用 Impeller 渲染。Impeller 在 OpenHarmony 上的表現(xiàn)取決于 GPU 驅(qū)動對圖形接口的支持情況低端設(shè)備偶爾會出現(xiàn)奇怪的渲染異常比如頁面花屏、圓角矩形邊緣發(fā)虛。這類問題排查思路先不要懷疑業(yè)務(wù)代碼先做一個對比實驗把引擎切換到舊的 Skia 渲染路徑看看同樣的頁面是否恢復(fù)正常。如果確實是 Impeller 與設(shè)備 GPU 驅(qū)動兼容性的問題項目里就先關(guān)閉 Impeller等設(shè)備驅(qū)動更新再開回去。關(guān)閉方法按當(dāng)前 flutter_flutter 分支的文檔來通常是配置引擎的渲染參數(shù)。這里提醒一句如果你將來要適配一批不同品牌、不同 GPU 的 OpenHarmony 設(shè)備一定要把這個開關(guān)做成可動態(tài)配置的別寫死在代碼里否則出廠后的排查會非常被動。中文渲染方面OpenHarmony 的 Flutter 適配基本沒問題但我遇到過自定義字體失效的情況尤其是設(shè)置字重或者斜體時部分設(shè)備會退化到系統(tǒng)默認(rèn)字體。這個問題多半不是引擎缺陷而是字體文件子集不完整。最終方案是直接把需要的字重都用靜態(tài)字體文件打進(jìn)去避免動態(tài)字體加載在低端設(shè)備上丟字。6. 復(fù)盤我在這個項目里踩過的實際坑6.1 Gradle 插件引用報錯apply 命令式閉包構(gòu)建過程中遇到一個比較典型的報錯原文大概是 “you are applying flutters main gradle plugin imperatively using the apply script method”。這個是因為在settings.gradle和build.gradle里用了老式的腳本方式引用 Flutter Gradle 插件而新的 flutter_flutter 分支要求通過插件 DSL 方式聲明。這個問題的處理說起來簡單但真實排查過程比較曲折。報錯只在你執(zhí)行 Gradle 任務(wù)時出現(xiàn)而且首次看跟 Flutter 關(guān)系不大容易往 Gradle 版本方向查。我修復(fù)方式是在settings.gradle里用pluginManagement聲明插件坐標(biāo)和版本工程模塊里再用plugins { id ... }引用刪掉原來apply from:的寫法。之后就再也沒出現(xiàn)過這個報錯。順帶一提類似的問題在安卓側(cè)也有但 OpenHarmony 的構(gòu)建鏈更容易因為這個問題中斷因為它的 Gradle 插件和 Flutter 插件之間版本匹配比安卓側(cè)更敏感。6.2 組件通信從總線模式改造成 Provider 的教訓(xùn)項目早期圖省事我用了一個全局事件總線日歷點擊發(fā)一個事件相冊頁面監(jiān)聽事件刷新。功能當(dāng)時也能跑但兩周后維護(hù)的時候我發(fā)現(xiàn)一個問題事件在代碼里滿天飛搜索一個事件的發(fā)射點和監(jiān)聽點全靠肉眼而且事件發(fā)多了還會出現(xiàn)重復(fù)監(jiān)聽、內(nèi)存泄漏的隱患。后來我把組件通信統(tǒng)一收斂到 Provider規(guī)則很簡單跨頁面共享的數(shù)據(jù)模型放 Provider頁面內(nèi)部臨時狀態(tài)用 StatefulWidget 或局部 State只在事件不需要觸發(fā) UI 重建時才考慮輕量事件 bus。改完之后代碼可讀性提升了一個檔次。日歷頁和相冊頁之間的通信從“我監(jiān)聽你、你通知我”的隱式鏈路變成“我改 Provider 狀態(tài)、你 watch 這個狀態(tài)”的顯式數(shù)據(jù)流。排查問題的時候直接看 Provider 的狀態(tài)變化比翻事件監(jiān)聽列表快十倍。6.3 從 Android 遷移到 OpenHarmony 時的 API 差異清單如果之前做過 Android 版相冊遷移到 OpenHarmony 時不能只改一下包名就完事。媒體庫這一層 API 差異非常大我列一個實際遷移時最實用的對照表能力AndroidOpenHarmony權(quán)限聲明AndroidManifest.xmlmodule.json5媒體庫查詢MediaStore ContentResolverMediaLibraryManager FetchOptions縮略圖Loader 系列 Glide 等第三方系統(tǒng)縮略圖接口需指定尺寸文件路徑Uri File 操作URI 為主部分場景走 filePath相冊插入MediaStore insertMediaAssetChangeRequest這份對照表看著簡單真正遷移的時候每一個差異都可能憋出 bug。尤其是縮略圖這部分Android 習(xí)慣用 Glide 一把梭OpenHarmony 上用不上得回到系統(tǒng)縮略圖接口的思路上來。做這個項目踩了一圈坑之后我最大的體會是Flutter for OpenHarmony 目前的適配已經(jīng)能支撐起一個完整的、可日常使用的應(yīng)用但你要做好心理準(zhǔn)備真正消耗時間的不是寫頁面而是摸清兩個生態(tài)之間那些“看起來一樣但實際不一樣”的邊界。如果你準(zhǔn)備做同類項目我建議先把媒體庫讀取、權(quán)限、真機(jī)連接這三樣跑通再用日歷 Tab 作為主力場景去驅(qū)動其他功能整個開發(fā)節(jié)奏會順很多。