做 Flutter 鸿蒙化的团队,迟早会撞上一面墙:pub.dev 上那批成熟的 Flutter 三方库,绝大多数只维护了 Android 和 iOS 两个平台的实现,ohos这个平台标签在官方支持列表里根本不存在。今天要聊的contacts包就是这面墙上的典型一块砖。它是一个用于读取、写入系统通讯录的 Flutter 插件,帮上层业务统一处理联系人列表、电话、邮箱、头像这些数据的跨端获取。问题在于,当业务要跑上鸿蒙设备,直接依赖contacts会得到一个"不支持的平台"运行时异常,或者静默返回空数据。
我这次做的,就是给这个包补一套鸿蒙原生实现,把通讯录读取、权限动态申请、联系人新增编辑、变更监听这些能力全部接到 HarmonyOS 的通讯录能力上。整个过程走下来,我发现真正有分量的工作不在 Dart 层,而在三块:第一是搞懂鸿蒙 Flutter 插件到底怎么注册和通信;第二是吃透鸿蒙这边敏感的通讯录权限体系;第三是数据模型和跨端序列化的设计。这篇就把这三个层面的实战过程完整拆开,给后面做同类三方库鸿蒙化的人提供一份能直接照着落地的参考。
1. 项目概述与适配思路拆解
1.1 contacts 包到底解决了什么问题
先说清楚contacts包在 Flutter 生态里的定位。它在 pub.dev 上属于高频库,核心价值非常直接:让 Flutter 应用通过一套统一的 API 访问系统通讯录。Dart 侧你只需要调用ContactsService.getContacts(),就能拿到联系人列表;调用ContactsService.addContact()就能往系统通讯录写入一条记录。底层对 Android 的ContactsContract、iOS 的CNContactStore做了完整封装,开发者完全不用关心原生侧那一大堆查询条件和权限申请细节。
对业务方来说,这套能力几乎每个社交类 App 都会用到。比如做一个换机助手,用户授权后需要把旧手机的联系人完整拉出来;做一个名片扫描工具,识别完名片需要把结果回写到通讯录;再简单一点,哪怕只是在设置页加一个"获取本机联系人"的功能,也绕不开这套逻辑。所以当鸿蒙设备进入产品支持范围,这个包的适配优先级相当高。
但现实很骨感:我记得查官方仓库的时候,它的pubspec.yaml里的flutter插件平台声明只有android和ios,没有ohos。这意味着鸿蒙应用里一旦执行到ContactsService.getContacts(),MethodChannel 会发现没有原生端处理这个调用,直接抛MissingPluginException。所以我们要做的事情,本质上是把这个包缺失的"鸿蒙原生半边"补出来,而不是重写整个包。
1.2 为什么"鸿蒙化"不是把代码抄一遍
很多人第一反应是:既然 Android 实现有了,鸿蒙不也有一套通讯录接口吗,对着 Android 代码换成鸿蒙 API 不就行了?实际情况没有这么简单,我拆解下来主要有四层差异。
第一层是权限模型的差异。Android 那边READ_CONTACTS是危险权限,在运行时用requestPermissions申请一次就行,用户一旦授予基本能长期生效。鸿蒙这边的权限管理更细,ohos.permission.READ_CONTACTS属于敏感权限,需要在module.json5里声明,还需要在运行时通过abilityAccessCtrl动态申请。而且不同权限有不同的"权限等级"和"授权方式",使用场景和合规性要求也更严,申请逻辑不能照搬。
第二层是原生 API 的数据模型不同。Android 的ContactsContract用RawContacts、Data等多表关联的模型组织数据,一条联系人的手机号、邮箱分散在不同行里,查询的时候要拼接 URI、处理游标。鸿蒙的通讯录模块把联系人封装成了更现代的结构化对象,字段命名和层级关系都和 Android 不一样。所以不能粗暴地把Cursor遍历逻辑换成鸿蒙的contact模块接口,因为有更细节的坑,后文我会专门展开。
第三层是数据映射的字段差异。Android 联系人头像可能是一个content://URI,iOS 的头像可能是文件路径,鸿蒙这边头像的表达方式又不一样。如果只做一个最小可用的读取,也许能绕开头像,但要做到"深度集成"——也就是上层业务拿到跨端一致的数据结构——就必须把这些差异性字段全部归一化。
第四层是插件生命周期和线程模型的差异。鸿蒙的 Flutter 运行环境走的是 OpenHarmony 适配的那套运行时,插件注册、通道调用、原生异步结果回传的写法与 Android/iOS 并不完全一致。如果照搬 Android 插件里onAttach注册通道的写法,很可能遇到通道注册不生效、回调收不到结果的问题。
所以我的结论是:鸿蒙化适配本质上是一个"桥接层重写 + 数据层归一化 + 权限流程再造"的工程,不是简单的 API 替换。后面几章,我会把三个核心模块的完整实现拆开讲。
2. 鸿蒙侧插件架构:从 Flutter 到 ohos 的桥接链路
2.1 鸿蒙 Flutter 工程的目录结构与准入条件
动手之前,必须先确认你的工程已经是鸿蒙化的 Flutter 工程。现在市面上的主流方案是基于 OpenHarmony 适配的 Flutter SDK,用flutter create --platforms ohos之类的方式创建带有ohos目录的工程。它和标准 Flutter 工程的最大区别,就是多了一个用 ArkTS 写的鸿蒙原生模块。
一个典型的鸿蒙化 Flutter 插件工程,目录结构长这样:
contacts_plugin/ ├── pubspec.yaml ├── lib/ # Dart 侧实现 │ └── contacts.dart ├── android/ # Android 原生实现 ├── ios/ # iOS 原生实现 ├── ohos/ │ ├── build-profile.json5 │ ├── oh-package.json5 │ └── entry/ │ └── src/ │ └── main/ │ ├── module.json5 │ └── ets/ │ ├── MainAbility.ts │ └── contacts/ │ └── ContactsPlugin.ts # 鸿蒙侧插件在这个结构里,ohos就相当于 Android 的android目录、iOS 的ios目录,是整个插件的鸿蒙原生半边。要注意的是,oh-package.json5里要正确声明对 Flutter 引擎原生层的依赖,否则编译期就会报找不到 Flutter 基础类。
我经历过的第一个坑就在这里:编译工程后ohos目录下的代码报“找不到Plugin基类”之类的错误,排查半天发现是oh-package.json5的 dependencies 里漏掉了 Flutter 适配层的依赖,补上之后就能正常引入了。所以第一步不是急着写通讯录逻辑,而是把目录结构和依赖关系跑通,确保一个空插件能注册成功。
2.2 通道注册与插件生命周期的关键处理
contacts包在 Dart 侧通过MethodChannel发起调用,鸿蒙侧就必须注册一个同名的MethodChannel来接收这些调用。核心入口是一个实现了 Flutter 插件接口的原生类,代码骨架长这样:
// ContactsPlugin.ts import { FlutterPlugin, MethodCall } from '@ohos/flutter_ohos'; export class ContactsPlugin implements FlutterPlugin { onAttach(engine: FlutterEngine): void { engine.getMethodChannel('contacts_plugin') .setMethodCallHandler(this.handleMethodCall.bind(this)); } private async handleMethodCall(call: MethodCall): Promise<any> { switch (call.method) { case 'getContacts': return this.getContacts(); case 'addContact': return this.addContact(call.arguments); default: throw new Error('Method not implemented: ' + call.method); } } onDetach(engine: FlutterEngine): void { // 释放资源,解绑通道 } }这段代码里有几个值得强调的点。
第一,通道名必须和 Dart 侧完全一致。Dart 侧ContactsService里通常写着MethodChannel('contacts_plugin'),那么鸿蒙侧也要用完全相同的字符串。写错一个字符,就会出现通道能注册但 Dart 调用总是超时的诡异问题。遇到这种问题不要怀疑是不是网络问题,先用日志确认原生侧有没有真的收到method调用。
第二,onAttach和onDetach是一对生命周期回调。onAttach在插件被引擎加载时调用,在这里注册方法处理器;onDetach在引擎销毁时调用,要在这里解绑处理器、关闭游标、释放监听器。尤其像通讯录这种会持有一堆查询结果的插件,如果不释放资源,在页面反复重建的场景下很容易把内存打满。我们后面做联系人变更监听时,这个释放逻辑更加关键,不然会引发多次监听导致重复回调。
第三,回调传参尽量保持纯 JSON 的简单结构。原生侧返回的数据如果用Map<String, Object>,Dart 侧通过StandardMessageCodec解码的时候最省心。如果你在原生侧传了一个自定义对象回去,Dart 侧可能收到一个解不开的二进制对象,到时候排查起来非常痛苦。我的原则是:跨端数据一律用 Map 加基础的 List、String、int 类型,复杂对象在各自端内做转换。
3. 原生通讯录深度集成:权限、读取与回写
3.1 权限动态管理:READ_CONTACTS / WRITE_CONTACTS 的正确打开方式
鸿蒙通讯录的权限模型是我在整个适配过程中觉得最值得展开的部分。它不是简单地声明一个权限就能用,而是有“声明 + 动态申请 + 结果校验 + 兜底引导”四个环节。
第一步,在ohos/entry/src/main/module.json5里声明需要的权限。读取联系人用ohos.permission.READ_CONTACTS,写入联系人用ohos.permission.WRITE_CONTACTS。这一步是在应用打包层面告知系统你可能会使用这些敏感能力。
{ "module": { "requestPermissions": [ { "name": "ohos.permission.READ_CONTACTS" }, { "name": "ohos.permission.WRITE_CONTACTS" } ] } }第二步,运行时通过abilityAccessCtrl动态向用户弹窗申请。注意两点:一是这个申请必须有一个Context,最好用 UIAbility 的上下文;二是申请结果是通过回调或 Promise 返回的,必须拿到结果之后再做下一步,不能假设申请了就一定有权限。
import { abilityAccessCtrl } from '@kit.AbilityKit'; async function requestContactsPermission(context: Context): Promise<boolean> { const atManager = abilityAccessCtrl.createAtManager(); const result = await atManager.requestPermissionsFromUser( context, ['ohos.permission.READ_CONTACTS', 'ohos.permission.WRITE_CONTACTS'] ); return result.authResults.every((r) => r === 0); }这里有一个我反复踩到的坑:权限弹窗的触发时机。系统会检查你当前 App 是否已经授权过,如果授权过就不会再弹窗,而是直接返回已授权的结果;如果用户之前拒绝过,再次调用requestPermissionsFromUser大概率不会重新弹窗,而是直接返回拒绝。这个行为和部分 Android 机型的高版本系统很相似,但鸿蒙在文案层面更加严格。
因此,第三步就要做拒绝场景的兜底。当用户拒绝授权后,我们不能静默地什么都不做,更不能反复弹窗“逼”用户授权。正确的做法是:在 Dart 侧统一返回一个PermissionDeniedException给业务层,业务层根据自身设计弹出解释说明,引导用户去系统设置里手动打开权限。我们当时在getContacts()内部做了一个小的状态机:先检查权限状态,没有权限就抛异常,有权限才发起原生查询。
注意:千万不要把两个不同能力(读和写)的权限混在一次申请里当一回事。有些业务场景只需要读取,写入权限就没必要申请。鸿蒙的权限管理提倡最小化申请,多申请一个不需要的权限,审核和用户许可成本都会上升。
3.2 联系人读取与统一数据模型设计
权限打通之后,才轮到通讯录核心功能:读取联系人。鸿蒙侧的数据源是@kit.ContactKit里的contact模块,读取接口返回的是一个联系人数组,每个联系人对象里包含姓名、电话号码、邮箱、头像、公司、职位等字段。实现读取的大致流程如下:
import { contact } from '@kit.ContactKit'; async function queryAllContacts(): Promise<Array<any>> { const contacts = await contact.queryContacts({ // 查询条件留空,表示全量获取 holderId: -1, searchCondition: { field: contact.ContactFiled.NAME, value: '' } }); return contacts.map((item) => ({ id: item.id, displayName: item.name?.fullName ?? '', phoneNumbers: (item.phoneNumbers ?? []).map((p) => ({ number: p.phoneNumber, label: p.label })), emails: (item.emails ?? []).map((e) => ({ email: e.email, label: e.label })), avatar: item.avatar?.url ?? '' })); }这里要注意三点:
第一点是“全量获取”的性能风险。如果设备里存了几千个联系人,一次性全量读取再跨端传输,体验会非常差。我在实际测试中,一个三千联系人的设备,全量读取加 JSON 序列化加通道传输,耗时可能到两三秒,页面卡顿是肉眼可见的。所以我在原生查询里加了分页参数,每页返回 200 个联系人,Dart 侧通过多次调用聚合完整列表,同时在每次分页间隙让出主线程,避免原生侧长期占住通道导致 UI 掉帧。
第二点是空值处理。鸿蒙联系人返回的数据里,邮箱可能是空的,头像可能是空的,公司字段可能是空的。如果直接用item.email去取值,大概率得到 undefined,序列化成一个不存在的 key 之后,Dart 侧解码出的对象字段就不稳定。我对每一个字段都做了默认值兜底,保证返回的 JSON 结构恒定,Dart 侧的实体类解析起来才足够可靠。
第三点是跨端数据模型的抽象。contacts包原本在 Dart 侧定义了Contact、PhoneNumber、EmailAddress等实体,我们要做的是让鸿蒙侧返回的数据能无缝映射到这些实体上。所以我在 Dart 侧新增了一个fromOhosMap的工厂方法,显式处理每个字段的转换,而不是依赖 JSON 自动序列化。这样即使以后鸿蒙侧字段名变了,也只需要改这一个小方法。
class Contact { final String id; final String displayName; final List<PhoneNumber> phoneNumbers; final List<EmailAddress> emails; final String avatar; factory Contact.fromOhosMap(Map<String, dynamic> map) { return Contact( id: map['id']?.toString() ?? '', displayName: map['displayName']?.toString() ?? '', phoneNumbers: (map['phoneNumbers'] as List? ?? []) .map((e) => PhoneNumber.fromJson(e)) .toList(), emails: (map['emails'] as List? ?? []) .map((e) => EmailAddress.fromJson(e)) .toList(), avatar: map['avatar']?.toString() ?? '', ); } }3.3 联系人新增、编辑与删除的回写流程
只做读取还不能叫“深度集成”,contacts包本身还支持写入。鸿蒙侧新增联系人的逻辑,是把 Dart 侧传过来的结构化数据映射成鸿蒙的联系人对象,调用插入接口写回系统通讯录。
async function addContact(args: any): Promise<string> { let newContact = new contact.Contact(); newContact.name = { fullName: args['displayName'] ?? '' }; // 手机号、邮箱等字段映射 newContact.phoneNumbers = (args['phoneNumbers'] ?? []).map((p: any) => ({ phoneNumber: p['number'], label: p['label'] })); const id = await contact.createContact(newContact); return id; }这一步有个容易被忽略的点:写入前必须确认已经拿到WRITE_CONTACTS授权,否则createContact会直接抛权限异常。所以我在 Dart 侧addContact内部做了和getContacts一样的权限检查流程,不管业务方有没有手动申请,都保证写入前有权限。
另一个坑是数据校验。通讯录系统对数据的容忍度比我们想象的低,比如空的手机号、超长的姓名、非法字符。我在写入前做了基本校验:至少包含一个可用的手机号或邮箱,姓名字段长度限制在合理范围内,有超长或包含控制字符的数据直接抛业务异常,而不是把脏数据写进系统通讯录。因为系统通讯录一旦写入脏数据,用户想删掉会非常麻烦,这个责任我们承担不起。
删除和编辑的流程类似,鸿蒙侧都有对应的更新接口。编辑时要注意传入联系人 ID 必须来自查询结果,用一套自己编造的字符串 ID 去更新,大概率只能得到一个“找不到联系人”的异常。我在调试时吃过这个亏,后来所有更新操作都强制要求 ID 必须由queryContacts返回,从数据源头杜绝这类问题。
3.4 头像与自定义字段的特殊处理
头像这块是通讯录适配里最琐碎的部分。Android 的头像是通过ContentUris拼出来的 URI,iOS 的头像可能在沙盒文件里,而鸿蒙联系人对象的头像字段通常是一个图库资源标识。Dart 侧原有的Contact.avatar字段如果直接塞入这个标识,业务方拿去Image.network是加载不了的。
我的处理方式是,在鸿蒙侧把头像统一转换成可访问的路径或者 base64 数据:如果鸿蒙 API 直接能拿到头像文件的本地路径,就返回文件路径;如果拿不到路径而只能拿到资源对象,就先把头像解码为字节流,按照固定大小(比如 96x96)压缩后转成 base64 字符串返回。这样一个头像大约几千字节,虽然比直接传 URL 重一些,但对越小越多的联系人列表来说,整体带宽消耗尚可接受。关键是业务方拿到之后,一个Image.memory就能显示,跨端代码不用再分平台判断。
自定义字段是另一个容易被忽略的点,比如联系人备注、纪念日、自定义标签。鸿蒙通讯录支持相当多的扩展字段,但contacts包在 Dart 侧没有完整暴露这些字段。我的方案是保留一个extra字段,在鸿蒙侧把支持的扩展字段序列化成 JSON 子对象放进去,Dart 侧不解析具体内容,只是原样保留。这样既不会破坏原有结构,又给未来业务扩展留了口子。
4. 联系人数据处理实战:排序、去重与同步
4.1 中文姓名排序:别用内置的字母排序
通讯录数据拿回来,最典型的需求就是按姓名排序。但如果直接按拼音首字母排序,很多团队会踩进一个坑:不同手机上的排序结果不一致。原因是鸿蒙系统内置的排序逻辑和 Flutter Dart 侧的字符串默认排序规则并不一样,尤其涉及中文联系人时,String.compareTo按 Unicode 码点排出来的顺序完全是乱的。
所以我在 Dart 侧单独实现了一个中文联系人排序函数。思路是先把每个联系人的显示姓名转换成拼音,再按拼音首字母和全拼排序,字母相同的再按姓名本身兜底排序。为了把中文转拼音,我给工程里引入了一个轻量级的拼音转换库,在初始化时把可能存在的中文字符转换一次并缓存,避免每个联系人排序都触发一次转换导致耗时翻倍。
实操中的排序代码大概长这样:
int compareContacts(Contact a, Contact b) { final pinyinA = _cachePinyin(a.displayName); final pinyinB = _cachePinyin(b.displayName); int result = pinyinA.compareTo(pinyinB); if (result != 0) return result; return a.displayName.compareTo(b.displayName); }这个方案处理了姓氏多音字的绝大多数情况,虽然做不到像专业通讯录 App 那样完美识别全部多音字,但在实用场景下已经足够稳定。排序之后,我还顺手做了一个 A-Z 分组的逻辑,把联系人按首字母分组放到一个 Map 里,这样上层做右侧字母索引导航栏的时候可以直接复用。
4.2 联系人去重合并:同一联系人的多条记录
真实设备上的联系人数据远没有文档展示的那么干净。系统通讯录里大量存在“同一个联系人存了两条记录”的现象,比如一个是手动录入的,一个是微信同步过来的,还有一种是同一个人的手机号出现在两条不同姓名记录里。如果不做去重,业务方拿到的列表就会显得很脏。
我在适配层做了一个去重合并的模块,核心逻辑是:
- 身份证级别的去重:如果有联系人有相同的手机号,且号码长度超过 7 位,就大概率是同一个人。合并时保留记录更完整的那条,把电话、邮箱等字段合并到主记录上。
- 姓名近似去重:显示名相同,手机号也相同或部分相同的情况下,直接合并。
- 不激进合并:仅凭姓名相同但手机号完全不同,我不做合并。因为同名不同人的情况太常见,激进合并会造成错误的用户画像。
去重模块放在 Dart 侧做成一个纯函数,方便业务方按需调用。测试时我用一份 5000 条真实的脏数据跑了一遍,去重后大概能合并掉 15% 的记录,效果非常可观。
提示:去重操作一定要保留原始联系人 ID。否则合并记录后用户操作编辑,系统无法定位到原始记录,写入就会失败。
4.3 联系人变更监听与增量同步:EventChannel 的正确用法
通讯录不是一个静态数据源,用户在系统设置里新增、删除、修改联系人后,App 如果一直用旧数据会非常尴尬。所以适配里我还补了变更监听能力:当系统通讯录发生变化时,原生侧要主动通知 Dart 侧。
鸿蒙原生侧有联系人变更回调能力,可以在onAttach时注册监听,在onDetach时移除监听。但这里有个关键问题:Dart 侧怎么接收“原生主动推送”的事件?用MethodChannel不合适,因为它是单向调用。正确做法是引入EventChannel,让原生侧作为事件源,Dart 侧作为订阅端。
鸿蒙侧的监听器核心逻辑大概如下:
private contactChangeHandler: (data: any) => void = (data) => { this.eventSink?.success(JSON.stringify({ type: 'contactChanged', data: data })); }; onAttach(engine: FlutterEngine): void { // 注册 EventChannel engine.getEventChannel('contacts_events') .setStreamHandler({ onListen: (arguments, sink) => { this.eventSink = sink; contact.on('contactChange', this.contactChangeHandler); }, onCancel: () => { contact.off('contactChange', this.contactChangeHandler); this.eventSink = null; } }); }Dart 侧的订阅端则是标准的EventChannel用法。业务方调用listenContactChanges()之后,收到事件就自动触发联系人列表的重新拉取,这样通讯录数据基本能做到实时同步。但这里要注意一个现实问题:过度监听会导致频繁全量刷新。我们当时加了简单的节流策略,监听事件触发后 3 秒内的后续事件只做标记,3 秒后统一起一次增量刷新,避免短时间内多次弹起引起卡顿。
增量同步是我在这部分重点做的能力。原生侧监听回调会携带一个变更摘要,包含变更类型(新增、删除、修改)和新增/修改的联系人 ID。Dart 侧收到事件后,不用每次都getContacts()全量拉取,而是根据摘要做局部更新:新增的塞进列表,删除的从列表移除,修改的按 ID 替换。这个方案在实际测试中把联系人列表刷新耗时从秒级降到了毫秒级,效果非常显著。
5. 踩坑实录与问题排查技巧
5.1 权限异常:最容易被误判的“未实现”错误
我在适配过程中遇到次数最多、最让人上头的问题,都集中在权限异常上。一个典型的报错场景是:通道注册没问题、方法名没错、原生侧日志也在打印调用,但 Dart 侧就是收到一个PlatformException,错误内容是权限拒绝。第一次遇到这种问题,我以为是代码逻辑写错了,排查了大半天才发现是某个测试机上权限状态没有重置,系统缓存了之前的拒绝结果。
后来我总结了一套定位权限问题的标准流程:
- 先看系统设置里 App 的权限开关是否已经打开。
- 再看原生侧
requestPermissionsFromUser的返回结果,authResults数组里是否全部为 0。 - 确认
module.json5的权限声明是否真的被打进产物(有些工程会自动裁剪未使用的权限配置)。 - 最后才怀疑代码逻辑错误。
这套流程走完,90% 的权限问题都能定位。另外有个经验:测试机上的权限状态很难恢复,因为系统记录的是历史授权决定。我后来做了一套测试辅助菜单,每次测试前可以主动重置权限状态,保证自动化测试的可重复性。
5.2 大列表跨端传输:通道不是用来搬大文件的
联系人列表动辄几千条,每条的手机号、邮箱、头像加一起,序列化后的 JSON 可能有几百 KB 甚至几 MB。最开始我天真地以为MethodChannel传个 1MB 的字符串没问题,结果在低端设备上直接复现了长时间无响应。后来查资料和实践验证,MethodChannel 在鸿蒙 Flutter 环境下的数据量安全线远低于 Android,大数据量必须拆包。
我的最终方案是分页 + 压缩双管齐下:原生侧按 200 条一页分页返回,Dart 侧按页聚合;每页数据在原生侧用轻量压缩后再传,到 Dart 侧解压。实测下来,3000 条联系人的全量拉取从原来的 2.6 秒降到了 1.2 秒左右,而且页面切换时不再出现明显掉帧。
注意:头像数据是最容易撑爆通道的部分。头像默认用 base64 传输时,一个 500KB 的头像转成 base64 就接近 700KB。这部分我强制压缩到 96x96 后转 base64,单个头像不超过 8KB,数据量立刻降了一个数量级。
5.3 插件生命周期与内存释放:漏掉 onDetach 的下场
最后说一个隐蔽的坑。通讯录插件里如果注册了变更监听,却没有在onDetach里移除,会出现非常难排查的问题:页面销毁后,原生侧还在持续往一个已经失效的EventSink里写事件,轻则 Dart 侧收到异常,重则整个 Flutter 引擎在销毁时崩溃。
这个问题在 Android 插件时代就有,鸿蒙侧一样存在。排查思路是看崩溃日志里有没有“handler was already detached”之类的关键字。我的经验是,不管插件多简单,onAttach和onDetach的配对一定要养成肌肉记忆:onAttach里注册了任何监听器、通道、定时器,onDetach里就应该成对地解除。适配完成后我还专门列过一个资源清单,把每个插件的所有注册项列出来比对遗漏。
除了生命周期,还有一个容易忽略的点是线程模型。鸿蒙侧的通讯录接口有些是异步回调、有些是同步返回,如果直接在主线程同步执行耗时查询,会阻塞 UI。我的处理方法是把耗时操作全部放到Promise异步流程中执行,Dart 侧本身是异步的,原生侧只要保证不卡主线程,整体的交互体验就有保障。
另外,对于contacts包原生不支持的某个 API,比如按分组查询联系人、模糊搜索电话号码,我的做法是在 Dart 侧暴露一个通用的invokeMethod透传能力,业务方需要扩展时可以通过透传调用鸿蒙原生侧自己实现的逻辑。这样既保证了contacts包原有 API 的兼容,又给未来业务特性留足了扩展空间。
我个人的体会是,三方库鸿蒙化的核心并不在于把原生接口翻译一遍,而在于理解 Flutter 通道机制、操作系统权限模型和数据结构差异这三者之间的交叉地带。contacts这个库正好把这三个维度的难点全部覆盖,做完这一轮适配之后,再遇到其他依赖原生能力的 Flutter 库,整个工作流就会顺畅很多。把通道注册、权限管理、数据归一化、生命周期控制这些基础动作沉淀成一套固定的适配模板,才是这次实战最大的收获。