ArkTS 端云一体待办清单:8 个云函数撑起多端同步的工程细节
端云一体最容易被低估的地方不是写页面,而是把「一条待办从点击到落库再到另一台设备看见」这条链路做扎实。这篇记录我在鸿蒙开发实践中心完成「云同步待办清单」案例时的架构选择与工程细节:ArkTS 端侧、Serverless 云侧、8 个 FunctionGraph 云函数、APIG 专享版与 RDS MySQL 三张表。
一、为什么端侧不直连数据库
鸿蒙端侧直连 MySQL 在演示阶段很爽,但放到真实网络里有两个硬伤:数据库账号随安装包分发等于公开凭据;移动网络下的长连接质量不可控,重连逻辑会吃掉大量业务代码。把数据访问收口到云函数,端侧只面对一个 HTTP 接口层,是更稳的边界。
这样做还有个副作用收益:函数层天然是同步策略的落点。端侧只负责发起意图,服务端负责保证状态一致,冲突处理逻辑不必在每台设备上重复实现一遍。
二、整体架构
- 端侧:ArkTS + ArkUI,页面层只调 ApiClient,不感知云函数名称。
- 接入层:APIG 专享版,统一做鉴权校验、限流与 CORS。
- 计算层:8 个 FunctionGraph 云函数,按领域拆分而非按接口拆分。
- 存储层:RDS MySQL,users / sessions / tasks 三张表。
- 部署:deploy.sh 一键下发函数与 APIG 路由,verify_api.sh 做端到端验收。
把 APIG 放在函数前面是刻意选择:函数本身不做鉴权中间件,鉴权交给网关,函数只相信网关注入的 userId。这样函数的单元测试不需要伪造 token,测试成本明显下降。
三、三张表的数据模型
表结构尽量窄,把可变部分放在 JSON 字段里,但索引字段必须显式建列:
CREATE TABLE tasks (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id VARCHAR(32) NOT NULL,
title VARCHAR(200) NOT NULL,
done TINYINT(1) NOT NULL DEFAULT 0,
priority TINYINT NOT NULL DEFAULT 1,
due_at DATETIME NULL,
version INT NOT NULL DEFAULT 1,
updated_at DATETIME NOT NULL,
deleted TINYINT(1) NOT NULL DEFAULT 0,
KEY idx_user_updated (user_id, updated_at)
) DEFAULT CHARSET = utf8mb4;
idx_user_updated 是同步查询的命脉。端侧轮询只问「我这个用户在某个时间点之后有哪些变更」,没有这个联合索引,数据量一上来就会退化成全表扫描。deleted 用软删除而不是物理删除,否则增量同步拿不到删除事件,另一台设备会永远留着那条记录。
四、会话与鉴权
sessions 表只做一件事:把登录态映射成短期凭证。函数侧不存内存态,鉴权完全靠网关验签后的 userId 透传,因此任何一个函数实例被回收都不影响在线用户。
这里有个容易踩的坑:时间字段统一用 UTC 存储,端侧展示时再转本地时区。一开始我在函数里用了服务器本地时间写 updated_at,跨时区测试时同步顺序直接乱掉,表现为「老数据覆盖新数据」。
五、同步策略:轮询 + 乐观更新 + 版本号
端侧每 30 秒拉一次增量,写操作先改本地再发请求,失败回滚。冲突用单调递增的 version 解决:更新语句带上 version 条件,影响行数为 0 说明服务端已被别人改过,函数返回 409,端侧重新拉取该条目。
// 云函数 updateTask 的核心更新语句
const [rows] = await pool.execute(
'UPDATE tasks SET title=?, done=?, priority=?, due_at=?, version=version+1, updated_at=UTC_TIMESTAMP() ' +
'WHERE id=? AND user_id=? AND version=? AND deleted=0',
[title, done, priority, dueAt, id, userId, clientVersion]
);
if (rows.affectedRows === 0) return { code: 409, msg: 'version conflict' };
端侧的乐观更新必须配一条「回滚快照」,否则 409 之后用户看到的是自己刚输入的标题凭空消失。我的做法是每次写操作前把该项的旧对象压栈,失败时整对象还原,而不是逐字段回退。
六、8 个云函数的边界划分
- login / logout:会话建立与撤销,只碰 sessions 表。
- listTasks:增量拉取,只读,走 idx_user_updated。
- createTask / updateTask / deleteTask:写路径,全部带 version 校验。
- batchSync:端侧离线期间积压的多次写操作一次性提交,按客户端时间戳排序。
- profile:用户信息聚合,被多个页面复用。
按领域拆分而不是按 REST 动词拆成二十个函数,好处是冷启动次数可控,也避免函数之间互相调用形成链式超时。batchSync 是后来补的:端侧在地铁里断网十几分钟,逐条重放会产生大量 409,合并成一次提交后冲突率明显下降。
七、把验收脚本化
verify_api.sh 里串了一条完整的业务链路:注册 → 登录 → 建任务 → 改任务 → 制造版本冲突 → 增量拉取断言 → 软删除 → 再次拉取断言为空。案例文档里写「一键部署、端到端验收」,实际价值主要在这个脚本上——它把「接口能通」变成了「语义正确」。
#!/usr/bin/env bash
set -euo pipefail
TOKEN=$(curl -s -X POST "$API/login" -d '{"account":"demo","pwd":"demo"}' | jq -r .token)
TID=$(curl -s -X POST "$API/tasks" -H "Authorization: Bearer $TOKEN" \
-d '{"title":"写周报","priority":2}' | jq -r .id)
curl -sf -X PATCH "$API/tasks/$TID" -H "Authorization: Bearer $TOKEN" \
-d '{"title":"写周报v2","version":1}' > /dev/null
# 用过期 version 制造冲突,必须返回 409
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X PATCH "$API/tasks/$TID" \
-H "Authorization: Bearer $TOKEN" -d '{"title":"x","version":1}')
[ "$CODE" = "409" ] || { echo "conflict check failed: $CODE"; exit 1; }
echo "verify ok"
八、几个真实的坑
- 冷启动:函数首次调用 500ms 以上很常见,端侧首屏必须有骨架屏,不能假设 200ms 返回。
- 连接池:函数实例会被并发复用,pool 的连接上限要小于 RDS 最大连接数除以最大并发实例数,否则高峰期直接报 too many connections。
- ArkTS 严格类型:接口返回用 interface 描述并做了一层运行时校验,后端加字段不会崩,但字段改名会立即暴露,比隐式 any 安全得多。
- 轮询与页面生命周期的耦合:页面切到后台要停轮询,回到前台立即拉一次全量对齐,只做增量会出现「回来以后数据是旧的」这种错觉。
九、小结
这个案例的代码量不大,难的是把边界划清楚:端侧只管意图与展示,网关管鉴权,函数管语义与一致性,表结构管查询效率。把这四层各自的责任固定下来之后,加功能就变成在既定位置上填空,而不是每次重新讨论「这段逻辑该放哪儿」。
如果要在生产里继续往前走,下一步我会把 30 秒轮询换成长连接推送,并给 batchSync 加上幂等键——这两件事才是待办类应用真正变成「多端一致」的分水岭。
- 点赞
- 收藏
- 关注作者
评论(0)