【共创稿事节】从手机到车机:用 HarmonyOS 7 重做「拾鲜」的端侧识别、跨端同步与互动卡片
八月中旬,一个做社区生鲜的朋友找到我,说想给他们的会员做个"冰箱管家":拍一下冰箱里的东西,自动记下来,快过期了提醒一句,买菜前看一眼家里还剩什么。
需求听着不复杂,做起来全是脏活。拍照那一步要认包装上的字,认完要落本地库,库还得在手机、平板、车机、手表之间保持一致;用户拎着菜出门、坐进车里的时候,屏幕上得能看见"今晚必须吃掉哪几样"。
我们一共三个人:我负责端侧,老周兼后端和运维,设计是一位兼职的姑娘。八月底立项,九月中旬在 HarmonyOS 6 上跑通了第一版,九月底换到 HarmonyOS 7(API 26)重做了一轮。下面写的就是这一轮里比较硬的几段——端侧文字识别、跨端数据一致性、互动卡片和任务接续,以及几个回头看不值当的坑。
先把结论放前面,省得有人一路翻到最后:
-
端侧识别能用的场景比宣传里窄得多。我们最后只在"印刷包装"上做了自动录入,手写标签和促销小票全走手动,加个猜。
-
跨端一致性不要指望同步框架替你解决,冲突合并必须自己写,而且要写到字段级。
-
互动卡片是个"展示位",不是第二个入口。我们第一版把它当入口做,白干了两周。
-
五端同时上是个错误决定。真要做,先做手机 + 只读手表,跑通了再铺。
一、约束比需求先摆到桌上
朋友的需求只有一页纸,但我们自己列了四条约束,后面所有技术选型都是被这四条逼出来的:
-
必须离线可用。菜市场、地下车库、电梯里都没网,识别和录入不能等网络。
-
数据得在五块屏上一致。手机(Mate 60 Pro)、平板(MatePad Pro 13.2)、车机(借的一台问界 M9)、手表(WATCH 5)、PC(MateBook Pro)。PC 上其实只是"看看",但我们不想为它单独写一套。
-
不许常驻后台。这是个工具型应用,用户一周可能只打开三次,不可能靠长连接或定时任务续命。
-
安装包不能大。朋友的意思很直白:会员大多是中老年用户,看到 100MB 的安装包就不装了。
第 3 条和第 4 条基本判了"自己写一套同步服务 + 塞个 200MB 端侧模型"的死刑。我们最后落在 distributedKVStore + 系统提供的 Core Vision Kit 上,一行模型文件都没打包。
二、工程骨架:几个字段决定了后面两个月的痛苦
工程结构没什么新鲜的,entry 只当壳,装路由和首屏;feature_scan、feature_sync、feature_card 三个 HSP 各管一摊。用 HSP 而不是 HAR,是因为 HAR 会被每个引用它的模块各打一份,我们第一版用 HAR,release 包里同一份识别代码出现了三次,白白多了 6MB。

图 1:工程被拆成一个壳工程和三个 HSP,识别、同步、卡片各自独立。

图 2:module.json5 是这一轮改得最频繁的文件,权限和 deviceTypes 都在这里。
entry/src/main/module.json5 里下面这两段值得单独说,一是 deviceTypes,二是权限。
{
"module": {
"name": "entry",
"type": "entry",
// 五个端一次声明。手表和车机的差异在代码里用断点区分,不开第二个 HAP
"deviceTypes": ["phone", "tablet", "2in1", "wearable", "car"],
"deliveryWithInstall": true,
"installationFree": false,
"pages": "$profile:main_pages",
"abilities": [
{
"name": "EntryAbility",
"srcEntry": "./ets/entryability/EntryAbility.ets",
"exported": true,
"skills": [
{ "actions": ["ohos.want.action.home"] },
// 让负一屏、小艺、短信里的链接能直接落到详情页
{
"actions": ["ohos.want.action.viewData"],
"uris": [{ "scheme": "shixian", "host": "item", "pathPrefix": "/detail" }]
}
]
}
],
"requestPermissions": [
{
"name": "ohos.permission.CAMERA",
"reason": "$string:reason_camera",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
},
{
"name": "ohos.permission.DISTRIBUTED_DATASYNC",
"reason": "$string:reason_datasync",
"usedScene": { "abilities": ["EntryAbility"], "when": "inuse" }
}
]
}
}
deviceTypes 一次写五个是很爽,但代价是:每一个页面都要在 320vp 到 1440vp 之间活着。车机横屏、手表小圆屏、PC 大窗口,三种极端形态都得考虑。我们后来用 GridRow 的断点(320 / 600 / 840vp)做了一版布局,手表那端干脆做成独立的一套只读列表——同一个页面硬套五个端,最后一定是五个端都别扭。

图 3:五端同时在跑,改一行同步逻辑要等五台设备都装完,后来我们只保留了手机 + 平板 + 车机三台在调试链路上。
权限这块踩了个坑,放到第四节讲。这里先记一句:DISTRIBUTED_DATASYNC 是 user_grant,只写进 module.json5 不够,运行时还要 requestPermissionsFromUser,否则同步会失败——而且是静默失败。
代码规范这块,我们只加了一个 code-linter.json5,把性能相关的规则集打开。没搞得很复杂,因为规则一多,新人提交 MR 时全是黄线,最后大家都学会了点"忽略"。
{
"files": ["**/*.ets"],
"ignore": ["**/build/**", "**/oh_modules/**", "**/src/ohosTest/**"],
"ruleSet": ["@performance/recommended", "@typescript-eslint/recommended"],
"rules": {
"@performance/foreach-args-check": "error",
"@performance/avoid-overusing-custom-component": "warn"
}
}
foreach-args-check 这一条把我们坑得最惨,也救得最狠——第三节会提到。
三、端侧识别:把 OCR 从主线程挪走之后

图 4:整条链路只有第一步在主线程,其余都在子线程或卡片进程里。
识别链路本身不复杂:Camera Kit 拍照落临时文件,子线程解码成 1280 宽的 PixelMap,丢给 Core Vision Kit 做通用文字识别,拿回文本行,再用一套很土的规则解析成"名称 + 保质期"。
问题全在"子线程"这三个字上。
第一版我图省事,直接在拍照回调里 await textRecognition.recognizeText(...)。结果用户按下快门之后,预览画面定住 1.2 秒,返回键都点不动。用 Frame Profiler 一录,主线程上有一段 900 多毫秒的 ImageDecode,后面跟着 300 多毫秒的推理。Mate 60 Pro 都这样,更别说车机。

图 5:上半部分是改之前,主线程被两块长耗时任务占住;下半部分是改之后,只剩一次轻量的 UI 更新。
第二版改 TaskPool。这里有个细节:不要把 PixelMap 直接作为参数丢进 TaskPool。我们试过,要么报不可序列化,要么为了序列化多花三四十毫秒,比识别本身还贵。正确做法是把文件 uri 传过去,在子线程里自己重新解码——多解一次码,比跨线程搬一块 4MB 的像素数据便宜。
// feature_scan/src/main/ets/scan/RecognizeTask.ets
import { image } from '@kit.ImageKit';
import { fileIo } from '@kit.CoreFileKit';
import { textRecognition } from '@kit.CoreVisionKit';
import { BusinessError } from '@kit.BasicServicesKit';
// @Concurrent 标记的函数才会被 TaskPool 放到子线程执行
@Concurrent
export async function recognizeFromUri(uri: string): Promise<string[]> {
const file = fileIo.openSync(uri, fileIo.OpenMode.READ_ONLY);
const source = image.createImageSource(file.fd);
// 1280 宽足够认清包装上的小字,再大只是白白拖慢推理
const pixelMap = await source.createPixelMap({
desiredSize: { width: 1280, height: 960 }
});
const info: textRecognition.VisionInfo = { pixelMap };
const result = await textRecognition.recognizeText(info);
pixelMap.release();
source.release();
fileIo.closeSync(file);
return result.value
.split('\n')
.map((line: string) => line.trim())
.filter((line: string) => line.length > 0);
}
调用侧是这样,注意 taskpool.execute 的返回值要判空,我们线上真的遇到过一次子线程抛异常、主线程拿到 undefined 直接白屏:
import { taskpool } from '@kit.ArkTS';
import { hilog } from '@kit.PerformanceAnalysisKit';
const task = new taskpool.Task(recognizeFromUri, tempUri);
try {
const lines = await taskpool.execute(task) as string[];
this.parseResult(lines ?? []);
} catch (e) {
const err = e as BusinessError;
hilog.error(0x0001, 'Scan', 'recognize failed: %{public}s', err.message);
this.showManualInput();
}
识别本身跑通之后,才是真正麻烦的部分:识别出来的是字,不是数据。
包装上通常长这样:
巴氏鲜牛奶 950mL
生产日期 20260912
保质期 7天
也会有这样:
×× 手作 无糖豆浆
EXP 2026.09.26
还有这样(促销小票,我们踩过最多的):
会员价 ¥12.90 原价 ¥16.80
三种格式,三种解析策略。我们最后写了一版很朴素的解析器:先用正则找 生产日期|生产|MFG 和 保质期|EXP|有效期,找到日期就把日期归一化成 YYYY-MM-DD,找到天数就往前加;两个都找不到,就把整块文本丢给用户在确认页手填,同时用"历史上最常出现的默认保质期"给个建议值。这个词典只有 300 多条,全是我们自己一条条加进去的,成本很低,效果比想象中好。
准确率这块我说实话,测试集是我们自己拍的 240 张照片:
所以产品上我们做了个很硬的取舍:只有"印刷包装"走自动录入,其余全部手动,识别结果只作为候选词展示。刚上线那周有用户反馈"怎么有时候认不出来",我们复盘了一下,全是手写标签——用户不知道我们是故意不做,只觉得"这 AI 不行"。
后来在确认页加了一行小字:"手写标签建议手动输入,识别率约六成"。投诉没了,手动录入的比例从 38% 涨到 55%。这事给我一个教训:能力的边界要主动告诉用户,不要指望用户自己摸索出来。
四、跨端同步:字段级 LWW 是被逼出来的
同步用的是 distributedKVStore,SINGLE_VERSION,一条食材记录一个 key。选它而不是自己搭一套,理由很实在:省掉了账号体系和鉴权,设备在同一账号同一局域网下直接同步,离线写入也能在下次连上后补齐。
真正费时间的是冲突。
一个典型场景:晚上 8 点,用户在客厅拿平板更新了"牛奶还剩 1 盒",同时手机在另一个房间(离线)把同一条记录的保质期从 9-19 改成了 9-21。两台设备重新连上,KV 层的策略是整条 LWW(最后写入者胜),于是要么"剩 1 盒"丢了,要么"新的保质期"丢了,怎么选都是错的。
我们统计了内测两周的冲突日志,一共 137 次冲突,其中 132 次是"两个人改的不是同一个字段"。也就是说,只要做字段级合并,96% 的冲突根本不需要用户介入。
于是写入的时候不写整条记录,写"补丁":
// feature_sync/src/main/ets/data/ItemRepository.ets
import { distributedKVStore } from '@kit.ArkData';
// 每个字段一个时间戳 + 设备标识,合并时逐个字段比
type Field<T> = { v: T; t: number; d: string };
export interface ItemPatch {
id: string;
deleted?: boolean;
// 每个字段自带时间戳,合并时按字段比
name?: { v: string; t: number; d: string };
expireAt?: { v: string; t: number; d: string };
count?: { v: number; t: number; d: string };
}
export class ItemRepository {
private store: distributedKVStore.SingleKVStore | null = null;
async init(context: Context): Promise<void> {
const manager = distributedKVStore.createKVManager({
context,
bundleName: 'com.muhe.shixian'
});
this.store = await manager.getKVStore<distributedKVStore.SingleKVStore>('shixian_items', {
createIfMissing: true,
encrypt: false,
backup: false,
autoSync: true,
kvStoreType: distributedKVStore.KVStoreType.SINGLE_VERSION,
securityLevel: distributedKVStore.SecurityLevel.S1
}) as distributedKVStore.SingleKVStore;
this.store.on('dataChange', distributedKVStore.SubscribeType.SUBSCRIBE_TYPE_REMOTE,
(data: distributedKVStore.ChangeNotification) => {
// 只回补发生变化的 key,不整表重刷
this.applyPatches(data.updateEntries);
});
}
async patch(p: ItemPatch): Promise<void> {
const local = await this.readPatch(p.id);
const merged = mergePatch(local, p);
await this.store?.put(p.id, JSON.stringify(merged));
}
}
// 字段级 LWW:时间戳新者胜;时间戳相同用设备标识兜底,保证两端算出同一个结果
function pick<T>(a?: Field<T>, b?: Field<T>): Field<T> | undefined {
if (!a) return b;
if (!b) return a;
if (a.t !== b.t) return a.t > b.t ? a : b;
return a.d > b.d ? a : b;
}
export function mergePatch(local?: ItemPatch, remote?: ItemPatch): ItemPatch {
const l = local ?? { id: remote!.id };
const r = remote!;
return {
id: l.id,
deleted: (l.deleted ?? false) || (r.deleted ?? false),
name: pick(l.name, r.name),
expireAt: pick(l.expireAt, r.expireAt),
count: pick(l.count, r.count)
};
}
删除用墓碑:不真删,标 deleted: true,本地保留 7 天再做物理清理。这块我们犯过一次错——一开始直接 delete key,结果另一台设备离线时把这条记录重新推上来,"删掉的鸡蛋又回来了",用户以为见了鬼。墓碑加 7 天窗口之后,这个问题消失。
剩下那 4% 的同字段冲突,我们选择接受 LWW,但加了一个补救:详情页底部显示"最后修改:MatePad Pro,2 小时前"。冲突时用户至少知道数据被谁覆盖过,能自己改回来。
这一节的坑不在代码里,在权限:
DISTRIBUTED_DATASYNC是user_grant。module.json5里声明了,put也会成功返回,但数据就是不出这台设备。我们第一版的同步回调里把异常catch住只打了warn日志,结果内测第三天老周问我"为什么两台设备数据不一样",我盯了三个小时日志才想起来权限没申请。
import { abilityAccessCtrl } from '@kit.AbilityKit';
// 首次进入同步页时申请,不要放在 EntryAbility 启动时,用户根本不知道你在要什么
const atManager = abilityAccessCtrl.createAtManager();
const result = await atManager.requestPermissionsFromUser(this.context,
['ohos.permission.DISTRIBUTED_DATASYNC']);
if (result.authResults[0] !== 0) {
this.showSyncDisabledTips();
}
五、互动卡片:48 次刷新额度该怎么花?
卡片是朋友最看重的功能,理由也很朴素:"我妈不会翻 App,但她会看桌面。"
feature_card/src/main/resources/base/profile/form_config.json:
{
"forms": [
{
"name": "expire_card",
"displayName": "$string:form_expire_display",
"description": "$string:form_expire_desc",
"src": "./ets/widget/pages/ExpireCard.ets",
"uiSyntax": "arkts",
"window": { "designWidth": 720, "autoDesignWidth": true },
"colorMode": "auto",
"isDefault": true,
"updateEnabled": true,
"scheduledUpdateTime": "18:00",
"updateDuration": 0,
"defaultDimension": "2*4",
"supportDimensions": ["2*4", "4*4"]
}
]
}
第一版我把 updateDuration 设成 1(也就是 30 分钟一刷),还额外挂了个 15 分钟的定时器去 updateForm。上线前做压力测试才发现:系统对卡片刷新有配额,超额部分直接丢弃,不会有任何回调报错。具体数字我记不太准了,文档上写的是按 30 分钟一个窗口、一天有个上限(我印象是 48 次),总之别拿它当定时器用。日志里看着"我刷了 214 次",实际生效的可能只有十几次,还白白抬了功耗。用户看到的是"卡片上的数字有时候不更新"。

图 6:2*4 和 4*4 两种尺寸都得在 DevEco Studio 的卡片预览器里过一遍,别只截好看的做桌面截图。
改成两个来源之后才正常:
-
定点刷新:
updateDuration: 0+scheduledUpdateTime,我们只留 18:00 一个点。这个时间点是设计姑娘定的——"下班回家开冰箱之前"。 -
主动刷新:用户在应用里改了数据,立刻推一次。这是用户感知最强的一次刷新,也是最该花的额度。
// 主应用侧:数据变了就主动推
async function refreshAllCards(items: Item[]) {
const urgent = items.filter(i => daysLeft(i.expireAt) <= 2);
const data = formBindingData.createFormBindingData({
urgentCount: urgent.length,
top3: urgent.slice(0, 3).map(i => ({ name: i.name, days: daysLeft(i.expireAt) })),
updatedAt: formatTime(Date.now())
});
for (const formId of await loadFormIds()) {
try {
await formProvider.updateForm(formId, data);
} catch (e) {
// 卡片已被移除,顺手清掉存的 id
await removeFormId(formId);
}
}
}
loadFormIds() 这里有个不得不说的设计:卡片进程和主应用进程不共享内存。FormExtensionAbility.onAddForm 里拿到的 formId,主应用是看不见的,必须落盘。我们用 preferences 在同一个应用沙箱里存一份 id 列表,onAddForm 写、onRemoveForm 删。
// feature_card 侧:把 formId 存下来,主应用才找得到
onAddForm(want: Want): formBindingData.FormBindingData {
const formId = want.parameters?.[formInfo.FormParam.IDENTITY_KEY] as string;
this.saveFormId(formId); // 写入 preferences
return formBindingData.createFormBindingData({ urgentCount: 0, top3: [] });
}
也考虑过 dataProxyEnabled(数据代理刷新),它能省掉不少手动推的活,但它要求数据走 DataShare 暴露,我们这套 KV 结构改造成本不低,而且卡片只展示三个字段,主动推足够。这里没有标准答案,团队小的时候,能少一层就少一层。
改完之后的数据:内测期间日均刷新请求从 214 次降到 9 次左右,卡片内容过期(显示的数字和实际不符)的比例从 11.3% 降到 0.7%。
车机上的卡片是另一套麻烦。车机横屏、默认深色、还要遵守驾驶安全规范——文字不能太小、不能有强动画。我们给车机单独做了 4*2 的横版布局,只显示"今晚要吃掉 3 样",点一下进详情,不做任何滑动交互。开车的时候,卡片能看一眼就够了,别指望用户在上面操作。

图 7:同一份清单在三块屏上的样子。车机那版我们砍掉了图标和进度条,只留文字。
六、任务接续:从一个 want 参数说起
任务接续的场景很具体:用户在手机上翻到"牛奶,还剩 1 天",出门上车,希望车机直接停在那一页。
实现上主要两件事:onContinue 里存状态,onCreate 里恢复。跨端迁移的参数通过 want.parameters 传,我们只传一个路由字符串:
// EntryAbility.ets
onContinue(wantParam: Record<string, Object>): AbilityConstant.OnContinueResult {
// 只传路由,别传业务数据——数据在 KV 里,两边都能读到
wantParam['shixian.route'] = this.currentRoute ?? '/';
return AbilityConstant.OnContinueResult.AGREE;
}
onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void {
const route = want.parameters?.['shixian.route'] as string;
if (route) {
AppStorage.setOrCreate('restoreRoute', route);
}
}
这里踩了个很典型的坑:第一版我把"当前页面的完整数据快照"序列化后塞进 wantParam,图片路径列表也在里面(base64 过的),参数体积到了 100 多 KB。结果是接续过去之后页面白屏——参数被截断了,解析直接抛异常。改成只传 route、数据从 KV 重新读,问题消失,接续耗时也从 1.4 秒降到 300 多毫秒。
顺带说一句,onContinue 里能做的事很少,别在里面 await 网络请求。我们最早想"接续时顺便拉一次最新数据",结果接续直接超时失败。正确姿势是先 AGREE 过去,页面起来之后再触发一次同步。
下一步想做的是接意图框架:让用户在负一屏或者对话里说一句"冰箱里还有几盒牛奶",直接落到对应的条目上。现在只做到用 viewData 的 scheme 承接外部链接,意图那一层还没跑通,所以这篇文章里先不写,等踩完坑再说。
七、改完之后到底快了多少?
数字都是 DevEco Studio 的 Profiler 和 Launch 分析面板测的,Mate 60 Pro(7.0.0.115)、release 包、每次测三遍取中位数:
启动这块的收益主要来自三件事,跟识别、同步都没关系:把 feature_card 的初始化挪到首帧之后、Navigation 的首页不做同步 IO、以及把首屏那个 200 条的列表从 ForEach 换成 LazyForEach 加 cachedCount(5)。

图 8:HAR 换 HSP 之后,同一份识别代码在包里从三份变成一份,release 包直接掉了 10MB 出头。
列表那个更夸张。换之前滑动时能明显看到白块,foreach-args-check 规则一直在报 ForEach 里用了 index——我们当时没管。换成 LazyForEach + IDataSource 之后,创建开销降了一大截,帧率直接从 52 干到 104。这件事让我对 code linter 的态度变了:性能规则报的错,基本都是真的。
八、几个不值当的坑
写在这里,省得别人再走一遍:
九、复盘
做完这一轮,有三点体会,说不上多深刻,但确实是我们拿时间换的。
第一,端侧 AI 的能力边界要在排期之前摸清,不是做完再摸。 我们一开始按"识别准确率 90%"排的工期,实际手写标签只有六成,产品逻辑整个要重做一遍。如果先花两天做 200 张照片的摸底测试,能省下后面两周。
第二,跨端一致性不应该出现在 UI 层。 我们第一版在页面里做"合并",两个页面各写一遍,bug 也是各修一遍。挪到 Repository 层之后,冲突逻辑只有一处,测试也好写。
第三,卡片要做减法。 我们花了整整两周给卡片做"点击交互 + 滑动切换 + 页内翻页",最后全删了。用户在桌面上看卡片的时间大概是 1.5 秒,能读到的只有一行字和一个数字。现在卡片上只有一句话:"今晚要吃掉 3 样",下面三行名字。足够了。
如果让我重新做一遍,我会先把手机端做到能交付,再补一个只读的手表端,跑两周真实用户数据,再决定要不要上车机和 PC。五端齐发听起来很"全场景",实际上是把调试成本乘了五。
项目还在内测,几百个会员在用。最近在试的一件事是把"这周买了什么、扔了什么"做成月度小结,扔掉的比买的多,说明采购预测有问题——这大概是这个应用真正有意思的地方,也是下一篇文章的事了。
本文所述项目与数据均来自团队内测记录,设备型号、版本号以当时的开发环境为准。代码为便于说明做过简化,去掉了业务字段和埋点。
-End-
更多推荐

所有评论(0)