最近我把一个家庭相册App的实际开发任务落在了 Flutter for OpenHarmony 上,核心功能自然是相册浏览和回忆列表。所谓回忆列表,就是把相册里零散的照片,按照时间自动聚合成“某年某月”的回忆卡片,点击卡片再进入该时间段内的照片流。听起来挺简单,真正实现时却涉及媒体库权限适配、原生通道桥接、时间线聚合、缩略图加载、长列表性能等一系列问题。这篇文章就是我在这个项目里完整跑通的方案汇总,适合已经在跑Flutter、想落地到OpenHarmony设备,或者正在做相册类App的朋友直接参考。
1. 为什么用Flutter做OpenHarmony家庭相册
1.1 Flutter适配OpenHarmony的工程接入方式
先说结论:Flutter官方SDK目前并不直接输出OpenHarmony应用包,需要把Flutter SDK替换成适配OpenHarmony的版本。我这里用的是社区里比较流行的某开源适配SDK,它保持了Flutter上层API不变,编译产物可以打进OpenHarmony的HAP包,安装到手机上跑起来。
工程搭建上,我建议你先把适配后的flutter bin目录配置到PATH环境变量,然后执行flutter doctor检查。注意适配SDK对Flutter版本有锁定,不要随便升级到最新版本,否则编译HAP时可能报运行时找不到符号。项目里会有两个核心工程目录:一个是我们熟悉的Flutter工程(lib目录下全是Dart代码),另一个是OpenHarmony工程壳(包含entry、AppScope、module.json5等),构建时Flutter代码会被编译成中间产物供壳工程引用。
我最初踩过一个坑:直接在flutter run里开始调式,结果一直起不来。后来才明白,OpenHarmony侧必须先能通过hvigor构建出HAP包,调试时再选择“连上设备安装HAP”的方式启动。简单说,Flutter侧的热重载能力在部分适配SDK上还不完全好用,我通常是把UI逻辑的改动先在模拟器上验证,再整包安装到真机上。
1.2 家庭相册项目的整体模块拆分
整个家庭相册App如果铺开做,功能可以很重:多用户、云同步、人脸聚类、地图足迹、视频回忆等等。但我在这个项目里聚焦的是“本地相册 + 回忆列表”这条主线,所以模块是这样拆的:
- 媒体库服务层:负责权限申请、照片查询、缩略图获取,全部通过MethodChannel桥接到OpenHarmony原生侧
- 数据仓库层:把照片元数据(ID、URI、创建时间、宽高)统一封装成PhotoItem模型,并做本地缓存
- 回忆聚合引擎:把照片按时间分组,生成“回忆卡片”数据模型
- UI展示层:回忆列表主页、分组网格、回忆详情页
- 状态管理:中等规模项目我直接用Provider,没有引入太重框架,方便团队里其他人快速接手
为什么不用Bloc或Riverpod?因为在这个App里状态流的复杂度不高,Provider的ChangeNotifier足够覆盖相册数据的刷新和回忆列表的更新。真正复杂的不是状态管理,而是原生侧查询性能和列表滚动体验,所以要把精力花在对的地方。
2. 媒体库权限与照片读取
2.1 权限申请与原生侧照片查询
OpenHarmony的媒体库访问权限和Android类似,需要在module.json5里声明。我们这里需要读取图片基本信息,申请的是ohos.permission.READ_IMAGEVIDEO。如果你还要做地理标记之类的功能,才需要额外申请ohos.permission.GET_MEDIA_LOCATION。
权限声明完了还不够,运行时还需要动态申请,并在授权回调里通知Flutter侧继续拉数据。这个时序问题很重要,我后面会专门展开讲。
原生侧建议单独封装一个媒体库服务类,不把查询逻辑写在Ability里。核心是使用媒体库的查询接口,按照date_added倒序查询,支持分页。很重要的一点:不要一次性查全量照片,尤其是手机上照片数量动辄上万张时,全量查询会把UI主线程卡死。我最终采用的分页策略是每页500条,通过limit和offset滚动拉取。
原生侧方法通道的基本形式可以参考下面这种结构(伪代码示意,实际API以你使用的SDK版本为准):
import mediaLibrary from '@ohos.multimedia.mediaLibrary'; async function getAllPhotos(page: number, pageSize: number) { const mediaLib = mediaLibrary.getMediaLibrary(context); const fetchOp = { selections: '', orderBy: 'date_added DESC', limit: pageSize, offset: page * pageSize }; const assets = await mediaLib.getFileAssets(fetchOp); const result = []; assets.forEach((asset) => { result.push({ id: asset.id, uri: asset.uri, dateAdded: asset.dateAdded, width: asset.width, height: asset.height }); }); return result; }Dart侧的调用方式就清爽很多,只维护一个MethodChannel:
static const channel = MethodChannel('family_album/method'); Future<List<PhotoItem>> fetchPhotos(int page, int pageSize) async { final raw = await channel.invokeListMethod('getAllPhotos', { 'page': page, 'pageSize': pageSize, }) ?? []; return raw.map((e) => PhotoItem.fromMap(e)).toList(); }2.2 元数据模型与本地缓存设计
从原生侧拿到的原始Map,我会立刻转换成强类型模型:
class PhotoItem { final String id; final String uri; final DateTime dateAdded; final int width; final int height; const PhotoItem({ required this.id, required this.uri, required this.dateAdded, required this.width, required this.height, }); factory PhotoItem.fromMap(Map<dynamic, dynamic> map) { return PhotoItem( id: map['id'] as String, uri: map['uri'] as String, dateAdded: DateTime.fromMillisecondsSinceEpoch(map['dateAdded'] as int), width: map['width'] as int? ?? 0, height: map['height'] as int? ?? 0, ); } }元数据拿到之后,我建议直接落一份JSON缓存到本地。这样App冷启动时可以先渲染缓存数据,界面不是白屏,然后后台再增量拉取新的照片信息做对比更新。缓存的时机很关键:只有当前拉取页数达到照片总数(或暂时没有更多数据)时才算一次完整同步,这时才写缓存。如果只拉了前500条就写缓存,下次启动回忆列表会不完整。
此外,我还会在模型里冗余一个yyyyMMdd字符串字段,专门按天分组用。这个字段在聚合阶段会高频使用,预先算好可以避免在列表循环里反复做日期格式化。
3. 回忆列表的核心实现
3.1 时间线聚合算法:从照片数组到回忆卡片
回忆列表的本质是“把连续时间段的照片聚合成一组”。这里的关键是“连续”的判定标准。我采用的规则是:照片按日期排序后,如果两张照片的日期差小于等于3天,就合并到同一组;超过3天才算新组。
为什么是3天?因为家庭相册里的照片往往是节假日集中拍摄的,比如周末出游、过年期间,中间可能隔一两天。如果阈值设成1天,回忆卡片会碎得很厉害;设成7天,又会把两个不相关的小旅行合并成一条。3天是我在真实数据上试出来的平衡值。当然这个值应该做成可配置项,后续可以开放给用户自己调节。
聚合算法用Dart写起来很直白:
class MemoryGroup { final DateTime startDate; final DateTime endDate; final List<PhotoItem> photos; MemoryGroup(this.startDate, this.endDate, this.photos); } List<MemoryGroup> buildMemoryGroups(List<PhotoItem> photos) { final sorted = List<PhotoItem>.from(photos) ..sort((a, b) => a.dateAdded.compareTo(b.dateAdded)); final groups = <MemoryGroup>[]; if (sorted.isEmpty) return groups; var currentPhotos = <PhotoItem>[sorted.first]; var groupStart = _dateOnly(sorted.first.dateAdded); for (var i = 1; i < sorted.length; i++) { final photo = sorted[i]; final day = _dateOnly(photo.dateAdded); final diff = day.difference(groupStart).inDays; if (diff <= 3) { currentPhotos.add(photo); } else { groups.add(MemoryGroup( groupStart, _dateOnly(currentPhotos.last.dateAdded), List.unmodifiable(currentPhotos), )); currentPhotos = [photo]; groupStart = day; } } groups.add(MemoryGroup( groupStart, _dateOnly(currentPhotos.last.dateAdded), List.unmodifiable(currentPhotos), )); return groups.reversed.toList(); } DateTime _dateOnly(DateTime dt) => DateTime(dt.year, dt.month, dt.day);聚合完成后,每条回忆卡片的数据结构里应该有:起始日期、结束日期、封面图(取组里第一张或权重最高的一张)、照片数量、可选的标题文案。标题我会按跨度生成,比如“2024年5月的回忆”“那年春天的8张照片”。
3.2 回忆列表布局:CustomScrollView + SliverGrid 组合
回忆列表页面如果只用简单的GridView,会很难实现“分组头 + 照片网格”的混合滚动效果。我最终选用了CustomScrollView,把分组头和照片网格都作为Sliver塞进去。
页面结构大概是这样:
- 一个SliverToBoxAdapter放顶部的“年度回忆”大卡片,展示最近一年生成的照片数量统计
- 中间按组循环:每组先放一个SliverPersistentHeader作为分组头,设置为固定吸顶,滑动时能停在顶部;然后放一个SliverGrid来展示该组照片
- 点击某一张照片或整张卡片,通过Hero动画跳到回忆详情页
核心布局代码大致如下:
CustomScrollView( slivers: [ SliverToBoxAdapter(child: YearlySummaryCard()), for (final group in groups) ...[ SliverPersistentHeader( pinned: true, delegate: GroupHeaderDelegate(group), ), SliverGrid( gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 3, mainAxisSpacing: 2, crossAxisSpacing: 2, ), delegate: SliverChildBuilderDelegate( (context, index) => PhotoThumbnail(photo: group.photos[index]), childCount: group.photos.length, ), ), ], ], )这里有个细节容易忽略:SliverPersistentHeader的构造函数里,pinned: true意味着吸顶,但如果组与组之间间距很近,吸顶头会直接覆盖上一组最后的照片,视觉上有点拥挤。我在实际操作中给吸顶头加了半透明背景和底部阴影,保证下层图片还能隐约透出来,观感会好很多。
3.3 缩略图加载与内存缓存方案
相册类App最敏感的就是图片加载和内存。列表页里如果直接加载原图,几百个缩略图就会让内存飙升到几百MB,在OpenHarmony设备上大概率被系统杀掉。我的做法是:列表页永远只显示缩略图,原图在详情页点开时才请求。
原生侧提供一个getThumbnail方法,入参是照片ID和目标边长,返回压缩后的图片字节数组。实现上会调用FileAsset.getThumbnail拿到PixelMap,再压缩到指定尺寸、转成ByteArray返回Dart侧。
Dart侧不能每次去调原生通道,否则滚动时会频繁产生异步调用,肉眼可见掉帧。我封装了一个本地LRU缩略图缓存:
class ThumbnailCache { final LinkedHashMap<String, Uint8List> _items = LinkedHashMap(); final int maxCount; ThumbnailCache(this.maxCount); Uint8List? get(String key) { final value = _items.remove(key); if (value != null) { _items[key] = value; // 重新插入到末尾,表示最近使用 } return value; } void put(String key, Uint8List value) { _items[key] = value; if (_items.length > maxCount) { _items.remove(_items.keys.first); // 淘汰最久未使用的 } } }缓存上限我设为200项,按缩略图平均30KB计算,总占用约6MB,比较安全。你可以根据目标设备的实际内存情况动态调整,低端设备建议降到100。
在实际渲染时,列表项的build方法里只做缓存查找,如果命中就直接返回Image.memory;没有命中就显示占位图,然后异步加载,加载完再刷新对应项。这就避免了所有图片同时发起请求带来的拥堵。
3.4 分页加载与滚动防抖策略
家庭相册照片多的时候,一次聚合几千张照片的元数据是常态。如果列表一次性渲染所有照片,首次启动会卡顿很久。我采用的是分页加载:回忆列表先加载最近500张照片对应的回忆组,滑动到底部再拉下一页。
判断到底部的逻辑很常规:
scrollController.addListener(() { if (scrollController.position.pixels > scrollController.position.maxScrollExtent - 300) { loadMore(); } });但是这里有个很反直觉的坑:loadMore如果被快速触发多次,原生侧的查询请求会堆积,后一次返回的数据可能覆盖前一次的结果,导致列表重复或者跳动。我最后给loadMore加了serial执行锁,上一次请求没完成时,下一次调用直接return。同时每页加载完成后会重新调用一次聚合算法,增量更新回忆组,而不是拼接原始列表。
滚动防抖方面,我还在图片请求层做了“请求合并”:同一个照片ID在300毫秒内只发一次原生请求,后续的请求直接复用相同Future。这个对未来版分布式文件系统之类的场景也有帮助。
4. 实操过程中踩过的坑
4.1 权限弹窗与回调时序问题
第一次做权限适配时,我天真地以为App启动后在Flutter侧直接调getAllPhotos就行。实际上OpenHarmony的权限弹窗是在原生侧弹出来的,授权结果需要回调到一个Callback里。如果用户还没点“允许”,Flutter侧就已经发起查询,拿到的往往是空列表,界面就一直停留在“没有照片”的状态。
解决方案分两步:第一步,在原生侧Ability的onWindowStageCreate阶段就主动申请权限;第二步,把授权结果保存成一个全局状态,Flutter侧查询前先通过另一个通道检查权限状态。权限被拒绝时,界面不要只显示空态,而是给出一个“去设置页开启权限”的按钮。这个体验细节很影响用户对App的第一印象。
4.2 OpenHarmony的图片URI不能直接当文件路径用
这是我这次开发中折腾最久的一个问题。OpenHarmony媒体库返回的URI并不是普通文件路径,比如它可能是file://media/Photo/xxx这种形式。如果直接拿这个URI去构造File或Image.file,要么路径不存在,要么直接抛异常。
正确做法是走文件描述符:原生侧用fileAsset.open('rw')拿到FD,再通过FD读取数据。为了不把FD相关逻辑散落在Dart代码各处,我原生侧暴露了一个统一的loadImageBytes方法:Flutter侧传照片ID和尺寸,原生侧负责打开文件、压缩、返回字节数组。
这条经验非常重要:在设计桥接层时,千万不要把URI当路径暴露给Flutter侧,否则以后每个调用点都要吃一遍坑。尽量让原生侧封装完整的数据访问逻辑。
4.3 高频缩略图请求导致滚动掉帧
滚动时的缩略图请求是异步的,但异步不代表没代价。如果列表快速滚动,上百个缩略图请求会同时到达原生侧,原生侧会创建大量线程去压缩图片,反而把CPU资源耗尽。
我在原生侧加了一个简单的请求队列,使用串行或并发数受限的方式处理缩略图任务。Dart侧也做了配合:只在滚动停止时加载当前可视区域内的缩略图,滚动过程中只显示占位图。实测下来,快速滑动时帧率掉得明显少了很多。
4.4 低内存设备上的图片缓存上限调整
同一个App在不同的设备上表现差异很大。开发机运行流畅,换到低内存设备上滑几分钟就闪退。后来定位到是图片缓存过大:Flutter的ImageCache默认上限是100MB,对低端设备来说太高了。
我改造了启动逻辑,通过DeviceInfo拿到设备内存等级,如果低于某个阈值,就把ImageCache上限设为40MB,缩略图边长从200px降到120px,本地LRU缓存数也相应下调。这样虽然图片清晰度略降,但稳定性提升非常明显。
5. 常见问题与排查速查表
平时群里最常被问到的几个问题,我整理成了一张表,方便对照排查:
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| Flutter侧页面白屏,无数据 | 方法通道名称不一致,或原生侧没有注册通道 | 在原生侧打印日志,确认MethodChannel('family_album/method')两边完全一致;调用时捕获PlatformException |
| 照片一直显示占位图 | URI被当作普通文件路径使用,FD未正确读取 | 原生侧使用fileAsset.open拿FD后再读数据;Flutter侧不要直接Image.file(uri) |
| 回忆列表分组明显错乱 | 日期字段有时区偏移,或直接用了字符串比较 | 统一转换成本地时区,并预先存成yyyyMMdd字段再参与聚合 |
| 滚动时列表闪烁重复 | 分页请求没有锁,多次loadMore重叠 | 给loadMore加状态锁,上次请求未完成时直接丢弃进入下一次 |
| 权限开启后列表不刷新 | 授权回调没有通知Flutter侧重新拉数据 | 在原生侧授权成功后,主动通过EventChannel或再次调用Flutter侧的刷新方法 |
排查这类问题,我的习惯是先在原生侧加关键日志,再在Flutter侧用debugPrint跟踪每次通道调用的入参和返回值。MethodChannel最忌讳的就是两边方法名不一致或者参数类型不匹配,日志定位的速度比猜源码快得多。
另外再分享一个调试技巧:OpenHarmony的hdc工具也可以看系统日志,但过滤条件不如直接看HiLog方便。我在原生侧封了一个极简的日志函数,把每次查询的条数和耗时打到控制台,观察分页加载时的耗时曲线,很快就能发现是原生查询慢还是UI渲染慢。
从整个项目实施下来,我的体感是:Flutter跨端能力在OpenHarmony上已经可用,但相册类App这种高频访问媒体库的场景,核心工作量在原生桥接层和数据缓存设计上,UI层反而是相对简单的部分。如果你打算做类似项目,建议先把媒体库服务层做得足够稳,再动手画界面。分页查询、FD封装、缩略图缓存这三件事做好,后面基本就顺畅了。