ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

OpenHarmony上Flutter相册组件适配实战:从MethodChannel到鸿蒙插件

OpenHarmony上Flutter相册组件适配实战:从MethodChannel到鸿蒙插件 在 OpenHarmony 上跑 Flutter 应用最能拉开体验差距的就是相册这种重原生交互的场景。multi_image_picker_view作为 Flutter 社区里很顺手的多图选择组件本身提供了一套现成的网格展示、计数角标、预览弹层和删除交互但底层依赖image_picker这类原生插件。鸿蒙生态里image_picker默认是不工作的直接引入这个库要么编译不过要么点选图片完全没反应。这次我把multi_image_picker_view从 Dart UI 到鸿蒙原生能力这一整条链路完整打通实现了相册授权、多图选择、缩略图列表、大图预览、选图数量限制以及相册增量变化监听整体交互做到了接近原生相册的流畅程度。整个过程踩了不少坑这篇就是适配 OpenHarmony 的完整复盘写给正在做 Flutter 鸿蒙化改造的同学做参考。这篇文章涉及的内容从 Flutter 平台通道机制、鸿蒙插件注册方式到相册权限申请、缩略图缓存、大图降采样都有对应的实现思路和可复现代码。即使你对鸿蒙 API 还不熟只要跟着流程走一遍也能把multi_image_picker_view跑起来。当然前提是你得先有一个 OpenHarmony 开发板和对应的 Flutter 运行环境。1. 先搞清楚 multi_image_picker_view 到底依赖什么1.1 这个库的真实结构multi_image_picker_view从 pub.dev 拉下来看核心其实是个 UI 组件。它帮你封装了一排“已选图 加号按钮”的横向流动布局点击加号会触发图片选择选中后回调一个ListXFile给你。它不自己去碰相册所有跟系统相册打交道的事情都委托给了image_picker。这意味着什么意味着如果你只把它当黑盒用在鸿蒙上一定挂。因为image_picker在 OpenHarmony 上并没有原生实现MethodChannel 调过去之后原生侧无人响应Dart 层收不到结果组件就会卡在“选择中”状态或者直接抛MissingPluginException。我在适配前做的第一件事就是把multi_image_picker_view的源码完整读了一遍搞清楚它对外暴露的关键点MultiImagePickerView本身是 Widget初始图片通过ListXFile? initialImages传入点击添加按钮后内部调用ImagePicker().pickMultiImage()拿结果每次图片变化会回调onImagesChanged这个回调是 UI 层刷新和外部业务状态同步的关键selectionLimit参数控制最多选多少张预览弹层是自绘的纯 Dart 实现不依赖原生。所以这次适配的工程量并没有想象中那么大预览弹层、角标、删除按钮这些 UI 全部可以保留真正需要替换的只有“从相册拿图片”这一层底层实现。1.2 适配鸿蒙前必须画清的三条链路动手之前我先把要处理的东西拆成了三条链路。这个动作非常有用推荐你也先做一遍触发链路用户点“” →MultiImagePickerView内部调用ImagePicker→ MethodChannelpickMultiImage→ 鸿蒙原生拉起相册选择器 → 返回图片 URI 列表 → Dart 层包装成XFile→ 触发onImagesChanged。预览链路用户点已选图 → 弹层 PageView 加载大图。这条链路本身在纯 Dart UI 上但如果图片 URI 是鸿蒙的file://或自定义 schemeImage.network可能加载不了需要统一转换或者写一个支持该 URI 协议的 ImageProvider。状态同步链路选了几张、哪几张是新增、哪几张被删除、相册里图片变了要不要自动同步。这条链路在鸿蒙上最容易出问题因为 OpenHarmony 相册不是简单的“目录轮询”它有自己的媒体库变更通知机制想做好实时刷新必须靠原生事件推送。画完这三条链路你就明白真正的适配工作集中在第一条和第三条链路的原生侧UI 层几乎可以原封不动。2. 鸿蒙端插件架构与通道设计2.1 为什么必须自己写原生插件OpenHarmony 目前主流的 Flutter 运行方式是把 Flutter 引擎嵌入到 ArkTS/ArkUI 应用里的 hybrid 模式。Flutter 部分的 Dart 代码跑在 Flutter Engine 上但相册、相机、地理位置这些系统能力必须通过 ArkTS 侧的系统 API 来完成。所以鸿蒙适配的第一步就是拥有一个能响应 Dart 侧 MethodChannel 调用的原生插件模块。OpenHarmony 社区早期的做法是直接改引擎产物但对应用层开发者来说最稳的路径是用官方提供的 Flutter 插件开发模板以独立模块的方式维护一个插件工程。我从实际体验来说自己写插件比在业务工程里直接写window桥接要干净得多。插件可以独立发布、独立测试业务侧只通过 Dart 接口调用后续换机型、升 API Level 都只动插件内部不会牵一发而动全身。2.2 MethodChannel、EventChannel 的职责怎么划分我这次把通道拆成了三个各管一摊避免一把梭Channel 名称类型职责flutter_mip/photo_pickerMethodChannel权限申请、读取相册列表、批量选择图片、获取图片详情flutter_mip/photo_thumbMethodChannel按 size 请求缩略图字节流走二进制编码flutter_mip/album_changeEventChannel监听相册/图片变化增量通知 Dart 侧刷新为什么缩略图要单独拆一个通道因为相册列表动辄几百上千张一次性全部回传必然导致 UI 卡死。缩略图请求是高频、低延迟、带大小参数的给它独立通道可以单独控制并发数、回收优先级和编解码策略。EventChannel 的作用更关键。当用户在系统相册里删了一张照片或者外部程序往相册塞了新图片Dart 侧需要收到通知后重新拉取数据。这件事如果用轮询性能完全无法接受必须靠原生事件推送到 Dart。2.3 原生侧 FlutterPlugin 注册与生命周期管理鸿蒙插件的注册方式我以标准的 Flutter 插件模板为例。你需要创建一个实现FlutterPlugin接口的类然后在onAttachedToEngine回调里注册所有通道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相册变化时回调 }, onCancel: () { // 反注册相册监听 } }); } private async handleMethodCall(call: MethodCall): Promiseany { switch (call.method) { case requestPermission: { // 权限申请逻辑 } 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; } }这里有个细节必须重点说onDetachedFromEngine里一定要把所有 handler 置空并把 channel 引用释放掉。否则热重载时会注册两个相同 channelDart 侧调用总是命中最先注册的那个就会出现“新代码不生效”的诡异 bug。等应用切到后台再回前台FlutterEngine 可能经历 detach 和 attach 的周期如果 handler 没有正确清理还会出现重复回调。这块我建议写单元测试覆盖至少保证 attach/detach 两次后依然能正常收发。2.4 Federated Plugin 结构把 Dart 与原生实现彻底解耦如果你只做鸿蒙适配直接在multi_image_picker_view的 fork 里改代码也行。但如果你的项目还要同时维护 Android、iOS、Web 端我强烈建议采用 federated plugin 结构。所谓 federated plugin就是把“接口定义”“平台实现”“数据模型”拆成多个包。以这次适配为例mip_picker_platform_interface纯 Dart定义平台接口MipPickerPlatformmip_picker_ohos鸿蒙实现包里面包含 ArkTS 插件和对应的 Dart 调用封装业务侧依赖mip_picker它根据平台自动选择实现。这样做的最大好处是multi_image_picker_view的 UI 层完全不用动业务侧也不知道底层换了实现你只需要在mip_picker的工厂方法里通过defaultTargetPlatform判断运行时平台返回不同的实例即可。我来回重构了几次最后这个结构成了最稳的形态。3. 核心实现细节权限、相册读取、缩略图与预览3.1 相册权限申请的正确姿势OpenHarmony 的相册权限不像 Android 那样统一运行时弹窗它在module.json5里声明ohos.permission.READ_IMAGEVIDEO后仍然需要运行时请求。我用的是abilityAccessCtrl提供的权限申请接口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 表示授权成功 }有个细节很容易踩坑如果应用刚启动就立刻弹权限框用户还没看明白很大概率会拒绝。我的做法是先引导到选图入口等用户主动点了“选择图片”再发起权限请求授权成功率明显高很多。另外OpenHarmony 部分版本上READ_IMAGEVIDEO和READ_EXTERNAL_STORAGE是二选一授权关系如果同时申请会导致权限弹窗冲突。我实测下来只申请READ_IMAGEVIDEO就够了系统会把相册读权限连带处理。3.2 读取相册列表的关键参数读取相册数据用的是PhotoAccessHelper性能大头在getAssets接口。它支持分页式的 FetchOptions我强烈建议不要一上来就全量拉取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; // 先加载一页滚动到底再分页这里两个参数要解释一下fetchColumns字段一定要限定不限定会返回整条记录相册有几千张时内存和序列化开销都会爆炸successCount是每次拉取的条数先拉 60 张比较合适后续滚动到底部再拉下一页。如果一开始就拉 1000 张缩略图任何设备都顶不住。拉取结果后把每一张的 uri、id、创建时间返回给 Dart 侧Dart 用列表渲染第一屏。注意缩略图不要等列表全部返回后再批量请求而应该拿到前 60 条 URI 后立刻发请求边滚动边补。3.3 缩略图内存模型与缓存策略这是整个适配里最影响“丝滑度”的一环。我的方案是列表页单张缩略图尺寸固定为200x200根据 cell 大小动态计算但视觉上够用原生侧getThumbnail(size)返回 PixelMap在原生侧转成 JPEG 字节流再通过缩略图通道回传 DartDart 侧用内存缓存管理已解码的ui.ImageLRU 容量限制在 64MB超限自动回收同时设置 Flutter 全局imageCache的maximumSize和maximumSizeBytes防止图片缓存无限膨胀。代码示意Dart 侧缓存封装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)); } } }我实测下来200 张以内的相册列表第一屏 20 张全部显示完耗时大约 400ms后续滚动时每一屏的缩略图都能在滚动结束后 150ms 内补齐。如果低于这个标准多半是原生侧转字节流时用了大尺寸原图或者 Dart 侧缓存没生效每次滚动都重新解码。3.4 大图预览降采样与分块解码大图预览的 OOM 是高频问题。OpenHarmony 的 PixelMap 本身对超大图有支持但如果直接加载一张 5000x4000 的照片并转成ui.Image给预览页内存直接多出近 100MB路由切换时大概率崩溃。我的做法是先在原生侧做一次降采样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 以内单张大图的内存占用从 90MB 降到 15MB 左右多开两三张图缓存也不会挂。如果需要更极致的体验还可以叠加一个原生侧 LRU 缓存把最近查看的 5 张大图的 PixelMap 缓存住预览翻页时几乎零延迟。3.5 UI 层兼容XFile、Image.network 与自定义 ImageProvider鸿蒙相册返回的 URI 通常是file://media/...这种格式而multi_image_picker_view内部预览和控制图片时用的是Image.network或Image.file。这两类组件在鸿蒙 URI 上都会失效。我的处理方案是在 Dart 层做一个 URI 适配层把file://media/格式统一映射成自定义的MediaUri协议比如mip://resolved/123。然后实现一个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)); } }这个 Provider 替换完成之后UI 层所有Image.network的调用都指向mip://协议底层走自己实现的解码头。好处是可控性极强缓存、解码配置、超时处理都在一个地方不会再被 Flutter 默认的网络图片缓存策略坑到。4. 完整适配流程实操记录4.1 环境准备与依赖替换先把环境列出来方便你对照OpenHarmony SDK API 12Flutter 3.22 的 OpenHarmony 分支引擎DevEco Studio 5.x Flutter 插件开发模板依赖替换上我直接 fork 了multi_image_picker_view把内部调用替换成自己的封装。pubspec.yaml里这样引用dependencies: multi_image_picker_view: git: url: https://your-gitlab.example/multi_image_picker_view.git ref: ohos-supportfork 的源码里只需要改一个文件把_openPicker()方法中ImagePicker的调用替换成MipImageSource.pickMultiImage()并确保返回类型一致。这样业务侧不用改动任何代码。4.2 Dart 端平台接口设计我没有在业务代码里散落 MethodChannel而是单独建了一个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 侧的数据模型用 URI 字符串作为主键比较稳妥。注意不要试图把 PixelMap 或者原生对象直接传给 Dart跨语言桥接只走可序列化类型否则会触发序列化错误。4.3 鸿蒙端原生实现核心方法逐段解析原生的关键在pickMultiImage方法里。要做的动作是确认权限拉起系统相册选择器返回选中的 URI 列表。这里用了 OpenHarmony 的photoViewHandler它会在应用内弹出系统级多选界面选完直接返回结果。这个方案比自绘网格多选框省事得多并且和系统相册 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; }这里有个细节isPhotoTakeSupported我开了这样用户在系统选择界面可以直接切相机拍照体验上比返回 Flutter 再调相机要连贯很多。如果你不希望用户混选把它设为false即可。ensurePermission的实现需要缓存一个 Promise避免用户连续点击时重复触发权限弹窗。我在第一次请求未完成时后续请求直接复用同一个 Promise等结果出来再统一交给两个调用方。4.4 接入 multi_image_picker_view 并替换数据源fork 之后业务侧的用法和原库完全一致MultiImagePickerView( initialImages: _selectedImages, selectionLimit: 9, onImagesChanged: (images) { setState(() _selectedImages images); }, )initialImages里放的是XFile对象。我额外做了一步在进入页面时预先调MipImageSource.pickMultiImage(limit: 0)只读列表不拉选择器把相册前 60 张的缩略图信息预热到缓存这样用户真正点开选择器时系统相册页面的返回速度会快不少。4.5 性能验证与三轮调优记录适配完之后我做了三轮调优每轮都有明确指标第一轮是缩略图并发。原生侧默认 12 个并发内存抖动明显滚动有掉帧。我降到 4 个并发并用队列控制滚动体感稳定了很多。第二轮是缓存优化。缩略图加了一个 64MB 的 LRU滚动回看图片不再重复加载也没有白闪。第三轮是事件合并。EventChannel 推送相册变化时Dart 侧做 300ms 的 debounce把多次通知合并成一次刷新避免用户连续删两张图导致列表刷两次。实测数据OpenHarmony 开发板 / 麒麟平台选取 9 张图全流程平均耗时 2.8s含权限弹窗与用户选择等待相册列表首屏 1.2s缩略图滚动无掉帧大图预览滑动帧率稳定在 55fps 以上。注意以上数据是单次适配的实测记录不同设备和系统版本会有差异。你适配时建议先固定一套基准测试对比调优前后数据再决定哪些优化要做。5. 实战中踩过的坑与排查技巧5.1 权限回调丢失第一次适配时发现用户点了允许Dart 侧却没有收到任何回调。查了半天原来是requestPermissionsFromUser的结果没有通过普通 Promise 返回而是依赖回调事件。解决方式是封装一个带回调转 Promise 的请求函数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); }); }记得在回调之后立即off掉监听否则下一次请求会触发两个回调造成状态混乱。5.2 相册监听回调不触发EventChannel 的相册变化监听在 API 12 上要注意注册时的上下文生命周期。如果你在onAttachedToEngine时拿到的 context 是宿主应用的 context 而不是 UIAbility 的 contextphotoAccessHelper的on(photoChange)可能不会触发。解决办法从插件 binding 里先取 UIAbilityContext优先用 UIAbilityContext 注册监听。另外事件流建立之后要注意在onCancel里反注册否则切后台再回前台会重复监听同一变化通知两次Dart 侧 debounce 也挡不住重复刷新。5.3 大图滑动返回时白屏这个坑很隐蔽。我用降采样后的 PixelMap 生成字节流传给 Dart但用户如果滑动得很快多次请求的返回顺序不一致就会导致图片串位或者白屏。严格解决方案是每次请求带一个requestIdDart 侧收到结果后校验 id 是否对应当前展示页不是就丢弃final response await _thumbChannel.invokeMethodMap(fetchThumbnail, { requestId: currentPageIndex, uri: uri, width: width, height: height, }); if (response[requestId] currentPageIndex) { setState(() _currentImage response[bytes]); }这个方法同样适用于相册列表的滚动场景只不过列表场景的 requestId 是 cell 的位置。5.4 临时文件残留与缓存清理鸿蒙相册返回的 URI 有些是临时选择路径长时间使用会在系统相册里留下空壳或缓存文件。我会在每次选择完成后把返回的 URI 统一转成持久可读路径并做一套文件生命周期管理应用退出时清理预览缓存目录超过 7 天的临时缩略图文件自动清除每次选择完成后主动删除只在上一次会话中使用的临时路径。这块虽然不影响功能但会影响应用在系统存储里的卫生程度。很多审核和用户体验问题都是这种细节累积出来的。5.5 常见问题速查表问题现象可能原因排查与解决点加号后无反应原生插件未注册或 MethodChannel 名称不一致检查onAttachedToEngine是否执行通道名是否与 Dart 侧一致MissingPluginException插件 detach 后未重新 attach检查onDetachedFromEngine是否清理了 handler权限弹窗反复出现权限结果回调未处理或重复注册监听用 Promise 封装回调后off掉监听缩略图滚动卡顿并发过高或缓存未生效并发降到 4检查 Dart 侧 LRU 缓存大图预览白屏返回顺序不一致增加 requestId 校验相册新增图片列表不刷新监听未注册或用错 context改用 UIAbilityContext确认photoChange回调触发选择多张图后返回耗时长全量拉取列表改用分页预取前 60 条缩略图6. 这个方案还能怎么扩展multi_image_picker_view这套适配思路跑通之后我把它沉淀成了一个内部通用的“相册服务模块”。后续如果要继续扩展可以直接在原生侧加方法视频选择复用photoViewHandler.select把 MIMEType 改成VIDEO_TYPE返回 Duration 信息自定义裁剪在原生侧拿到选中 URI 后用image.createImageSource再做一次裁剪和转码返回新的 URI文件路径持久化将临时 URI 转存到应用沙箱目录用photoAccessHelper的getPhotoAccessHelperAPI 拿可写路径相册分组按albumName字段分组在 Dart 侧实现二级折叠列表。这些扩展都绕不开一个核心把原生能力做好接口抽象让 Dart 侧只依赖稳定协议。只要这一点做扎实任何上层 UI 组件都能在鸿蒙上跑得很顺畅。最后分享一个实际体会越早把“自带 UI 组件 原生能力桥接”这个问题解耦后面的适配工作就越轻松。不要觉得加入一个大而全的框架一通兼容就行鸿蒙的相册 API 有自己的生态特点老老实实按媒体库模型去设计通道跑出来的效果才是最稳的。
返回列表