戰(zhàn):從MethodChannel到鴻蒙插件)
在 OpenHarmony 上跑 Flutter 應(yīng)用最能拉開體驗(yàn)差距的就是相冊這種重原生交互的場景。multi_image_picker_view作為 Flutter 社區(qū)里很順手的多圖選擇組件本身提供了一套現(xiàn)成的網(wǎng)格展示、計(jì)數(shù)角標(biāo)、預(yù)覽彈層和刪除交互但底層依賴image_picker這類原生插件。鴻蒙生態(tài)里image_picker默認(rèn)是不工作的直接引入這個(gè)庫要么編譯不過要么點(diǎn)選圖片完全沒反應(yīng)。這次我把multi_image_picker_view從 Dart UI 到鴻蒙原生能力這一整條鏈路完整打通實(shí)現(xiàn)了相冊授權(quán)、多圖選擇、縮略圖列表、大圖預(yù)覽、選圖數(shù)量限制以及相冊增量變化監(jiān)聽整體交互做到了接近原生相冊的流暢程度。整個(gè)過程踩了不少坑這篇就是適配 OpenHarmony 的完整復(fù)盤寫給正在做 Flutter 鴻蒙化改造的同學(xué)做參考。這篇文章涉及的內(nèi)容從 Flutter 平臺通道機(jī)制、鴻蒙插件注冊方式到相冊權(quán)限申請、縮略圖緩存、大圖降采樣都有對應(yīng)的實(shí)現(xiàn)思路和可復(fù)現(xiàn)代碼。即使你對鴻蒙 API 還不熟只要跟著流程走一遍也能把multi_image_picker_view跑起來。當(dāng)然前提是你得先有一個(gè) OpenHarmony 開發(fā)板和對應(yīng)的 Flutter 運(yùn)行環(huán)境。1. 先搞清楚 multi_image_picker_view 到底依賴什么1.1 這個(gè)庫的真實(shí)結(jié)構(gòu)multi_image_picker_view從 pub.dev 拉下來看核心其實(shí)是個(gè) UI 組件。它幫你封裝了一排“已選圖 加號按鈕”的橫向流動布局點(diǎn)擊加號會觸發(fā)圖片選擇選中后回調(diào)一個(gè)ListXFile給你。它不自己去碰相冊所有跟系統(tǒng)相冊打交道的事情都委托給了image_picker。這意味著什么意味著如果你只把它當(dāng)黑盒用在鴻蒙上一定掛。因?yàn)閕mage_picker在 OpenHarmony 上并沒有原生實(shí)現(xiàn)MethodChannel 調(diào)過去之后原生側(cè)無人響應(yīng)Dart 層收不到結(jié)果組件就會卡在“選擇中”狀態(tài)或者直接拋MissingPluginException。我在適配前做的第一件事就是把multi_image_picker_view的源碼完整讀了一遍搞清楚它對外暴露的關(guān)鍵點(diǎn)MultiImagePickerView本身是 Widget初始圖片通過ListXFile? initialImages傳入點(diǎn)擊添加按鈕后內(nèi)部調(diào)用ImagePicker().pickMultiImage()拿結(jié)果每次圖片變化會回調(diào)onImagesChanged這個(gè)回調(diào)是 UI 層刷新和外部業(yè)務(wù)狀態(tài)同步的關(guān)鍵selectionLimit參數(shù)控制最多選多少張預(yù)覽彈層是自繪的純 Dart 實(shí)現(xiàn)不依賴原生。所以這次適配的工程量并沒有想象中那么大預(yù)覽彈層、角標(biāo)、刪除按鈕這些 UI 全部可以保留真正需要替換的只有“從相冊拿圖片”這一層底層實(shí)現(xiàn)。1.2 適配鴻蒙前必須畫清的三條鏈路動手之前我先把要處理的東西拆成了三條鏈路。這個(gè)動作非常有用推薦你也先做一遍觸發(fā)鏈路用戶點(diǎn)“” →MultiImagePickerView內(nèi)部調(diào)用ImagePicker→ MethodChannelpickMultiImage→ 鴻蒙原生拉起相冊選擇器 → 返回圖片 URI 列表 → Dart 層包裝成XFile→ 觸發(fā)onImagesChanged。預(yù)覽鏈路用戶點(diǎn)已選圖 → 彈層 PageView 加載大圖。這條鏈路本身在純 Dart UI 上但如果圖片 URI 是鴻蒙的file://或自定義 schemeImage.network可能加載不了需要統(tǒng)一轉(zhuǎn)換或者寫一個(gè)支持該 URI 協(xié)議的 ImageProvider。狀態(tài)同步鏈路選了幾張、哪幾張是新增、哪幾張被刪除、相冊里圖片變了要不要自動同步。這條鏈路在鴻蒙上最容易出問題因?yàn)?OpenHarmony 相冊不是簡單的“目錄輪詢”它有自己的媒體庫變更通知機(jī)制想做好實(shí)時(shí)刷新必須靠原生事件推送。畫完這三條鏈路你就明白真正的適配工作集中在第一條和第三條鏈路的原生側(cè)UI 層幾乎可以原封不動。2. 鴻蒙端插件架構(gòu)與通道設(shè)計(jì)2.1 為什么必須自己寫原生插件OpenHarmony 目前主流的 Flutter 運(yùn)行方式是把 Flutter 引擎嵌入到 ArkTS/ArkUI 應(yīng)用里的 hybrid 模式。Flutter 部分的 Dart 代碼跑在 Flutter Engine 上但相冊、相機(jī)、地理位置這些系統(tǒng)能力必須通過 ArkTS 側(cè)的系統(tǒng) API 來完成。所以鴻蒙適配的第一步就是擁有一個(gè)能響應(yīng) Dart 側(cè) MethodChannel 調(diào)用的原生插件模塊。OpenHarmony 社區(qū)早期的做法是直接改引擎產(chǎn)物但對應(yīng)用層開發(fā)者來說最穩(wěn)的路徑是用官方提供的 Flutter 插件開發(fā)模板以獨(dú)立模塊的方式維護(hù)一個(gè)插件工程。我從實(shí)際體驗(yàn)來說自己寫插件比在業(yè)務(wù)工程里直接寫window橋接要干凈得多。插件可以獨(dú)立發(fā)布、獨(dú)立測試業(yè)務(wù)側(cè)只通過 Dart 接口調(diào)用后續(xù)換機(jī)型、升 API Level 都只動插件內(nèi)部不會牽一發(fā)而動全身。2.2 MethodChannel、EventChannel 的職責(zé)怎么劃分我這次把通道拆成了三個(gè)各管一攤避免一把梭Channel 名稱類型職責(zé)flutter_mip/photo_pickerMethodChannel權(quán)限申請、讀取相冊列表、批量選擇圖片、獲取圖片詳情flutter_mip/photo_thumbMethodChannel按 size 請求縮略圖字節(jié)流走二進(jìn)制編碼flutter_mip/album_changeEventChannel監(jiān)聽相冊/圖片變化增量通知 Dart 側(cè)刷新為什么縮略圖要單獨(dú)拆一個(gè)通道因?yàn)橄鄡粤斜韯虞m幾百上千張一次性全部回傳必然導(dǎo)致 UI 卡死??s略圖請求是高頻、低延遲、帶大小參數(shù)的給它獨(dú)立通道可以單獨(dú)控制并發(fā)數(shù)、回收優(yōu)先級和編解碼策略。EventChannel 的作用更關(guān)鍵。當(dāng)用戶在系統(tǒng)相冊里刪了一張照片或者外部程序往相冊塞了新圖片Dart 側(cè)需要收到通知后重新拉取數(shù)據(jù)。這件事如果用輪詢性能完全無法接受必須靠原生事件推送到 Dart。2.3 原生側(cè) FlutterPlugin 注冊與生命周期管理鴻蒙插件的注冊方式我以標(biāo)準(zhǔn)的 Flutter 插件模板為例。你需要創(chuàng)建一個(gè)實(shí)現(xiàn)FlutterPlugin接口的類然后在onAttachedToEngine回調(diào)里注冊所有通道import { FlutterPlugin, MethodChannel, EventChannel, MethodCall } from ohos/flutter_plugin; export default class MipPhotoPlugin implements FlutterPlugin { private channel: MethodChannel | null null; private eventChannel: EventChannel | null null; onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding): void { this.channel new MethodChannel(binding.getBinaryMessenger(), flutter_mip/photo_picker); this.channel.setMethodCallHandler(this.handleMethodCall.bind(this)); this.eventChannel new EventChannel(binding.getBinaryMessenger(), flutter_mip/album_change); this.eventChannel.setStreamHandler({ onListen: (args, eventSink) { // 保存 eventSink相冊變化時(shí)回調(diào) }, onCancel: () { // 反注冊相冊監(jiān)聽 } }); } private async handleMethodCall(call: MethodCall): Promiseany { switch (call.method) { case requestPermission: { // 權(quán)限申請邏輯 } case pickMultiImage: { // 相冊選擇邏輯 } default: throw new Error(Unknown method: call.method); } } onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding): void { this.channel?.setMethodCallHandler(null); this.eventChannel?.setStreamHandler(null); this.channel null; this.eventChannel null; } }這里有個(gè)細(xì)節(jié)必須重點(diǎn)說onDetachedFromEngine里一定要把所有 handler 置空并把 channel 引用釋放掉。否則熱重載時(shí)會注冊兩個(gè)相同 channelDart 側(cè)調(diào)用總是命中最先注冊的那個(gè)就會出現(xiàn)“新代碼不生效”的詭異 bug。等應(yīng)用切到后臺再回前臺FlutterEngine 可能經(jīng)歷 detach 和 attach 的周期如果 handler 沒有正確清理還會出現(xiàn)重復(fù)回調(diào)。這塊我建議寫單元測試覆蓋至少保證 attach/detach 兩次后依然能正常收發(fā)。2.4 Federated Plugin 結(jié)構(gòu)把 Dart 與原生實(shí)現(xiàn)徹底解耦如果你只做鴻蒙適配直接在multi_image_picker_view的 fork 里改代碼也行。但如果你的項(xiàng)目還要同時(shí)維護(hù) Android、iOS、Web 端我強(qiáng)烈建議采用 federated plugin 結(jié)構(gòu)。所謂 federated plugin就是把“接口定義”“平臺實(shí)現(xiàn)”“數(shù)據(jù)模型”拆成多個(gè)包。以這次適配為例mip_picker_platform_interface純 Dart定義平臺接口MipPickerPlatformmip_picker_ohos鴻蒙實(shí)現(xiàn)包里面包含 ArkTS 插件和對應(yīng)的 Dart 調(diào)用封裝業(yè)務(wù)側(cè)依賴mip_picker它根據(jù)平臺自動選擇實(shí)現(xiàn)。這樣做的最大好處是multi_image_picker_view的 UI 層完全不用動業(yè)務(wù)側(cè)也不知道底層換了實(shí)現(xiàn)你只需要在mip_picker的工廠方法里通過defaultTargetPlatform判斷運(yùn)行時(shí)平臺返回不同的實(shí)例即可。我來回重構(gòu)了幾次最后這個(gè)結(jié)構(gòu)成了最穩(wěn)的形態(tài)。3. 核心實(shí)現(xiàn)細(xì)節(jié)權(quán)限、相冊讀取、縮略圖與預(yù)覽3.1 相冊權(quán)限申請的正確姿勢OpenHarmony 的相冊權(quán)限不像 Android 那樣統(tǒng)一運(yùn)行時(shí)彈窗它在module.json5里聲明ohos.permission.READ_IMAGEVIDEO后仍然需要運(yùn)行時(shí)請求。我用的是abilityAccessCtrl提供的權(quán)限申請接口import { abilityAccessCtrl, common } from kit.AbilityKit; async function requestAlbumPermission(context: common.UIAbilityContext): Promiseboolean { const atManager abilityAccessCtrl.createAtManager(); const permissions [ohos.permission.READ_IMAGEVIDEO]; const grantResult await atManager.requestPermissionsFromUser(context, permissions); return grantResult.authResults[0] 0; // 0 表示授權(quán)成功 }有個(gè)細(xì)節(jié)很容易踩坑如果應(yīng)用剛啟動就立刻彈權(quán)限框用戶還沒看明白很大概率會拒絕。我的做法是先引導(dǎo)到選圖入口等用戶主動點(diǎn)了“選擇圖片”再發(fā)起權(quán)限請求授權(quán)成功率明顯高很多。另外OpenHarmony 部分版本上READ_IMAGEVIDEO和READ_EXTERNAL_STORAGE是二選一授權(quán)關(guān)系如果同時(shí)申請會導(dǎo)致權(quán)限彈窗沖突。我實(shí)測下來只申請READ_IMAGEVIDEO就夠了系統(tǒng)會把相冊讀權(quán)限連帶處理。3.2 讀取相冊列表的關(guān)鍵參數(shù)讀取相冊數(shù)據(jù)用的是PhotoAccessHelper性能大頭在getAssets接口。它支持分頁式的 FetchOptions我強(qiáng)烈建議不要一上來就全量拉取import { photoAccessHelper } from kit.MediaLibraryKit; const helper photoAccessHelper.getPhotoAccessHelper(context); let fetchOptions new photoAccessHelper.FetchOptions(); fetchOptions.fetchColumns [uri, name, size, date_added, orientation]; fetchOptions.sortKeys [{ key: date_added, descending: true }]; fetchOptions.fetchResult new photoAccessHelper.FetchResult(); fetchOptions.fetchResult.successCount 60; // 先加載一頁滾動到底再分頁這里兩個(gè)參數(shù)要解釋一下fetchColumns字段一定要限定不限定會返回整條記錄相冊有幾千張時(shí)內(nèi)存和序列化開銷都會爆炸successCount是每次拉取的條數(shù)先拉 60 張比較合適后續(xù)滾動到底部再拉下一頁。如果一開始就拉 1000 張縮略圖任何設(shè)備都頂不住。拉取結(jié)果后把每一張的 uri、id、創(chuàng)建時(shí)間返回給 Dart 側(cè)Dart 用列表渲染第一屏。注意縮略圖不要等列表全部返回后再批量請求而應(yīng)該拿到前 60 條 URI 后立刻發(fā)請求邊滾動邊補(bǔ)。3.3 縮略圖內(nèi)存模型與緩存策略這是整個(gè)適配里最影響“絲滑度”的一環(huán)。我的方案是列表頁單張縮略圖尺寸固定為200x200根據(jù) cell 大小動態(tài)計(jì)算但視覺上夠用原生側(cè)getThumbnail(size)返回 PixelMap在原生側(cè)轉(zhuǎn)成 JPEG 字節(jié)流再通過縮略圖通道回傳 DartDart 側(cè)用內(nèi)存緩存管理已解碼的ui.ImageLRU 容量限制在 64MB超限自動回收同時(shí)設(shè)置 Flutter 全局imageCache的maximumSize和maximumSizeBytes防止圖片緩存無限膨脹。代碼示意Dart 側(cè)緩存封裝class ThumbnailCache { static const int maxCacheBytes 64 * 1024 * 1024; final LinkedHashMapString, ui.Image _cache LinkedHashMap( equals: (a, b) a b, hashCode: (o) o.hashCode, ); ui.Image? get(String key) { final image _cache.remove(key); if (image ! null) { _cache[key] image; // 刷新 LRU 位置 } return image; } void put(String key, ui.Image image) { _cache[key] image; int total _cache.values.fold(0, (sum, img) sum (img.width * img.height * 4)); while (total maxCacheBytes) { final firstKey _cache.keys.first; _cache.remove(firstKey)?.dispose(); total _cache.values.fold(0, (sum, img) sum (img.width * img.height * 4)); } } }我實(shí)測下來200 張以內(nèi)的相冊列表第一屏 20 張全部顯示完耗時(shí)大約 400ms后續(xù)滾動時(shí)每一屏的縮略圖都能在滾動結(jié)束后 150ms 內(nèi)補(bǔ)齊。如果低于這個(gè)標(biāo)準(zhǔn)多半是原生側(cè)轉(zhuǎn)字節(jié)流時(shí)用了大尺寸原圖或者 Dart 側(cè)緩存沒生效每次滾動都重新解碼。3.4 大圖預(yù)覽降采樣與分塊解碼大圖預(yù)覽的 OOM 是高頻問題。OpenHarmony 的 PixelMap 本身對超大圖有支持但如果直接加載一張 5000x4000 的照片并轉(zhuǎn)成ui.Image給預(yù)覽頁內(nèi)存直接多出近 100MB路由切換時(shí)大概率崩潰。我的做法是先在原生側(cè)做一次降采樣import { image } from kit.ImageKit; async function loadPreviewImage(fileUri: string): Promiseimage.PixelMap { const imageSrc image.createImageSource(fileUri); const info await imageSrc.getImageInfo(); const targetWidth Math.min(info.size.width, 1600); const targetHeight Math.min(info.size.height, 1600); const pixelMap await imageSrc.createImagePixelMap({ desiredWidth: targetWidth, desiredHeight: targetHeight, desiredPixelFormat: image.PixelMapFormat.RGBA_8888, }); return pixelMap; }降采樣到 1600x1600 以內(nèi)單張大圖的內(nèi)存占用從 90MB 降到 15MB 左右多開兩三張圖緩存也不會掛。如果需要更極致的體驗(yàn)還可以疊加一個(gè)原生側(cè) LRU 緩存把最近查看的 5 張大圖的 PixelMap 緩存住預(yù)覽翻頁時(shí)幾乎零延遲。3.5 UI 層兼容XFile、Image.network 與自定義 ImageProvider鴻蒙相冊返回的 URI 通常是file://media/...這種格式而multi_image_picker_view內(nèi)部預(yù)覽和控制圖片時(shí)用的是Image.network或Image.file。這兩類組件在鴻蒙 URI 上都會失效。我的處理方案是在 Dart 層做一個(gè) URI 適配層把file://media/格式統(tǒng)一映射成自定義的MediaUri協(xié)議比如mip://resolved/123。然后實(shí)現(xiàn)一個(gè)MediaImageProvider它從緩存查圖查不到就走縮略圖通道異步拉取class MediaImageProvider extends ImageProviderMediaImageProvider { final String uri; final int width; final int height; override FutureMediaImageProvider obtainKey(ImageConfiguration configuration) { return SynchronousFuture(this); } override FutureImageStreamCompleter loadImage(MediaImageProvider key, ImageDecoderCallback decode) async { final bytes await MipImageSource.fetchThumbnail(uri, width, height); final buffer await ui.ImmutableBuffer.fromUint8List(bytes); final codec await ui.instantiateImageCodec(buffer); final frame await codec.getNextFrame(); return OneFrameImageStreamCompleter(Future.value(frame)); } }這個(gè) Provider 替換完成之后UI 層所有Image.network的調(diào)用都指向mip://協(xié)議底層走自己實(shí)現(xiàn)的解碼頭。好處是可控性極強(qiáng)緩存、解碼配置、超時(shí)處理都在一個(gè)地方不會再被 Flutter 默認(rèn)的網(wǎng)絡(luò)圖片緩存策略坑到。4. 完整適配流程實(shí)操記錄4.1 環(huán)境準(zhǔn)備與依賴替換先把環(huán)境列出來方便你對照OpenHarmony SDK API 12Flutter 3.22 的 OpenHarmony 分支引擎DevEco Studio 5.x Flutter 插件開發(fā)模板依賴替換上我直接 fork 了multi_image_picker_view把內(nèi)部調(diào)用替換成自己的封裝。pubspec.yaml里這樣引用dependencies: multi_image_picker_view: git: url: https://your-gitlab.example/multi_image_picker_view.git ref: ohos-supportfork 的源碼里只需要改一個(gè)文件把_openPicker()方法中ImagePicker的調(diào)用替換成MipImageSource.pickMultiImage()并確保返回類型一致。這樣業(yè)務(wù)側(cè)不用改動任何代碼。4.2 Dart 端平臺接口設(shè)計(jì)我沒有在業(yè)務(wù)代碼里散落 MethodChannel而是單獨(dú)建了一個(gè)mip_image_source.dart把與原生交互的邏輯全部集中class MipImageSource { static const _pickerChannel MethodChannel(flutter_mip/photo_picker); static const _thumbChannel MethodChannel(flutter_mip/photo_thumb); static FutureListString pickMultiImage({int limit 9}) async { final uris await _pickerChannel.invokeListMethodString(pickMultiImage, {limit: limit}); return uris ?? []; } static FutureUint8List fetchThumbnail(String uri, int width, int height) async { return await _thumbChannel.invokeMethod(fetchThumbnail, { uri: uri, width: width, height: height }); } }Dart 側(cè)的數(shù)據(jù)模型用 URI 字符串作為主鍵比較穩(wěn)妥。注意不要試圖把 PixelMap 或者原生對象直接傳給 Dart跨語言橋接只走可序列化類型否則會觸發(fā)序列化錯(cuò)誤。4.3 鴻蒙端原生實(shí)現(xiàn)核心方法逐段解析原生的關(guān)鍵在pickMultiImage方法里。要做的動作是確認(rèn)權(quán)限拉起系統(tǒng)相冊選擇器返回選中的 URI 列表。這里用了 OpenHarmony 的photoViewHandler它會在應(yīng)用內(nèi)彈出系統(tǒng)級多選界面選完直接返回結(jié)果。這個(gè)方案比自繪網(wǎng)格多選框省事得多并且和系統(tǒng)相冊 UI 完全融合private async pickMultiImage(call: MethodCall): Promisestring[] { const context getContext(this) as common.UIAbilityContext; await this.ensurePermission(context); const photoSelectResult await photoViewHandler.select({ MIMETypes: [photoAccessHelper.PhotoViewMIMETypes.IMAGE_TYPE], maxSelectNumber: call.arguments[limit] ?? 9, isPhotoTakeSupported: true, }); const uris: string[] []; photoSelectResult.photoSelectResult.forEach(item { uris.push(item.uri); }); return uris; }這里有個(gè)細(xì)節(jié)isPhotoTakeSupported我開了這樣用戶在系統(tǒng)選擇界面可以直接切相機(jī)拍照體驗(yàn)上比返回 Flutter 再調(diào)相機(jī)要連貫很多。如果你不希望用戶混選把它設(shè)為false即可。ensurePermission的實(shí)現(xiàn)需要緩存一個(gè) Promise避免用戶連續(xù)點(diǎn)擊時(shí)重復(fù)觸發(fā)權(quán)限彈窗。我在第一次請求未完成時(shí)后續(xù)請求直接復(fù)用同一個(gè) Promise等結(jié)果出來再統(tǒng)一交給兩個(gè)調(diào)用方。4.4 接入 multi_image_picker_view 并替換數(shù)據(jù)源fork 之后業(yè)務(wù)側(cè)的用法和原庫完全一致MultiImagePickerView( initialImages: _selectedImages, selectionLimit: 9, onImagesChanged: (images) { setState(() _selectedImages images); }, )initialImages里放的是XFile對象。我額外做了一步在進(jìn)入頁面時(shí)預(yù)先調(diào)MipImageSource.pickMultiImage(limit: 0)只讀列表不拉選擇器把相冊前 60 張的縮略圖信息預(yù)熱到緩存這樣用戶真正點(diǎn)開選擇器時(shí)系統(tǒng)相冊頁面的返回速度會快不少。4.5 性能驗(yàn)證與三輪調(diào)優(yōu)記錄適配完之后我做了三輪調(diào)優(yōu)每輪都有明確指標(biāo)第一輪是縮略圖并發(fā)。原生側(cè)默認(rèn) 12 個(gè)并發(fā)內(nèi)存抖動明顯滾動有掉幀。我降到 4 個(gè)并發(fā)并用隊(duì)列控制滾動體感穩(wěn)定了很多。第二輪是緩存優(yōu)化??s略圖加了一個(gè) 64MB 的 LRU滾動回看圖片不再重復(fù)加載也沒有白閃。第三輪是事件合并。EventChannel 推送相冊變化時(shí)Dart 側(cè)做 300ms 的 debounce把多次通知合并成一次刷新避免用戶連續(xù)刪兩張圖導(dǎo)致列表刷兩次。實(shí)測數(shù)據(jù)OpenHarmony 開發(fā)板 / 麒麟平臺選取 9 張圖全流程平均耗時(shí) 2.8s含權(quán)限彈窗與用戶選擇等待相冊列表首屏 1.2s縮略圖滾動無掉幀大圖預(yù)覽滑動幀率穩(wěn)定在 55fps 以上。注意以上數(shù)據(jù)是單次適配的實(shí)測記錄不同設(shè)備和系統(tǒng)版本會有差異。你適配時(shí)建議先固定一套基準(zhǔn)測試對比調(diào)優(yōu)前后數(shù)據(jù)再決定哪些優(yōu)化要做。5. 實(shí)戰(zhàn)中踩過的坑與排查技巧5.1 權(quán)限回調(diào)丟失第一次適配時(shí)發(fā)現(xiàn)用戶點(diǎn)了允許Dart 側(cè)卻沒有收到任何回調(diào)。查了半天原來是requestPermissionsFromUser的結(jié)果沒有通過普通 Promise 返回而是依賴回調(diào)事件。解決方式是封裝一個(gè)帶回調(diào)轉(zhuǎn) Promise 的請求函數(shù)function requestPermissionWithCallback(context: common.UIAbilityContext, permissions: string[]): Promisenumber { return new Promise((resolve) { context.on(requestPermissionsFromUserResult, (result) { resolve(result.authResults[0] ?? -1); context.off(requestPermissionsFromUserResult); }); abilityAccessCtrl.createAtManager() .requestPermissionsFromUser(context, permissions); }); }記得在回調(diào)之后立即off掉監(jiān)聽否則下一次請求會觸發(fā)兩個(gè)回調(diào)造成狀態(tài)混亂。5.2 相冊監(jiān)聽回調(diào)不觸發(fā)EventChannel 的相冊變化監(jiān)聽在 API 12 上要注意注冊時(shí)的上下文生命周期。如果你在onAttachedToEngine時(shí)拿到的 context 是宿主應(yīng)用的 context 而不是 UIAbility 的 contextphotoAccessHelper的on(photoChange)可能不會觸發(fā)。解決辦法從插件 binding 里先取 UIAbilityContext優(yōu)先用 UIAbilityContext 注冊監(jiān)聽。另外事件流建立之后要注意在onCancel里反注冊否則切后臺再回前臺會重復(fù)監(jiān)聽同一變化通知兩次Dart 側(cè) debounce 也擋不住重復(fù)刷新。5.3 大圖滑動返回時(shí)白屏這個(gè)坑很隱蔽。我用降采樣后的 PixelMap 生成字節(jié)流傳給 Dart但用戶如果滑動得很快多次請求的返回順序不一致就會導(dǎo)致圖片串位或者白屏。嚴(yán)格解決方案是每次請求帶一個(gè)requestIdDart 側(cè)收到結(jié)果后校驗(yàn) id 是否對應(yīng)當(dāng)前展示頁不是就丟棄final response await _thumbChannel.invokeMethodMap(fetchThumbnail, { requestId: currentPageIndex, uri: uri, width: width, height: height, }); if (response[requestId] currentPageIndex) { setState(() _currentImage response[bytes]); }這個(gè)方法同樣適用于相冊列表的滾動場景只不過列表場景的 requestId 是 cell 的位置。5.4 臨時(shí)文件殘留與緩存清理鴻蒙相冊返回的 URI 有些是臨時(shí)選擇路徑長時(shí)間使用會在系統(tǒng)相冊里留下空殼或緩存文件。我會在每次選擇完成后把返回的 URI 統(tǒng)一轉(zhuǎn)成持久可讀路徑并做一套文件生命周期管理應(yīng)用退出時(shí)清理預(yù)覽緩存目錄超過 7 天的臨時(shí)縮略圖文件自動清除每次選擇完成后主動刪除只在上一次會話中使用的臨時(shí)路徑。這塊雖然不影響功能但會影響應(yīng)用在系統(tǒng)存儲里的衛(wèi)生程度。很多審核和用戶體驗(yàn)問題都是這種細(xì)節(jié)累積出來的。5.5 常見問題速查表問題現(xiàn)象可能原因排查與解決點(diǎn)加號后無反應(yīng)原生插件未注冊或 MethodChannel 名稱不一致檢查onAttachedToEngine是否執(zhí)行通道名是否與 Dart 側(cè)一致MissingPluginException插件 detach 后未重新 attach檢查onDetachedFromEngine是否清理了 handler權(quán)限彈窗反復(fù)出現(xiàn)權(quán)限結(jié)果回調(diào)未處理或重復(fù)注冊監(jiān)聽用 Promise 封裝回調(diào)后off掉監(jiān)聽縮略圖滾動卡頓并發(fā)過高或緩存未生效并發(fā)降到 4檢查 Dart 側(cè) LRU 緩存大圖預(yù)覽白屏返回順序不一致增加 requestId 校驗(yàn)相冊新增圖片列表不刷新監(jiān)聽未注冊或用錯(cuò) context改用 UIAbilityContext確認(rèn)photoChange回調(diào)觸發(fā)選擇多張圖后返回耗時(shí)長全量拉取列表改用分頁預(yù)取前 60 條縮略圖6. 這個(gè)方案還能怎么擴(kuò)展multi_image_picker_view這套適配思路跑通之后我把它沉淀成了一個(gè)內(nèi)部通用的“相冊服務(wù)模塊”。后續(xù)如果要繼續(xù)擴(kuò)展可以直接在原生側(cè)加方法視頻選擇復(fù)用photoViewHandler.select把 MIMEType 改成VIDEO_TYPE返回 Duration 信息自定義裁剪在原生側(cè)拿到選中 URI 后用image.createImageSource再做一次裁剪和轉(zhuǎn)碼返回新的 URI文件路徑持久化將臨時(shí) URI 轉(zhuǎn)存到應(yīng)用沙箱目錄用photoAccessHelper的getPhotoAccessHelperAPI 拿可寫路徑相冊分組按albumName字段分組在 Dart 側(cè)實(shí)現(xiàn)二級折疊列表。這些擴(kuò)展都繞不開一個(gè)核心把原生能力做好接口抽象讓 Dart 側(cè)只依賴穩(wěn)定協(xié)議。只要這一點(diǎn)做扎實(shí)任何上層 UI 組件都能在鴻蒙上跑得很順暢。最后分享一個(gè)實(shí)際體會越早把“自帶 UI 組件 原生能力橋接”這個(gè)問題解耦后面的適配工作就越輕松。不要覺得加入一個(gè)大而全的框架一通兼容就行鴻蒙的相冊 API 有自己的生態(tài)特點(diǎn)老老實(shí)實(shí)按媒體庫模型去設(shè)計(jì)通道跑出來的效果才是最穩(wěn)的。