做了个什么东西
我有一个独立开发的存钱 App 叫「聚沙攒钱」,iOS 版上线快两年了。核心功能就是设一个储蓄目标,比如攒钱买耳机或者攒旅行基金,每次存钱会有硬币掉落动画,配合成就徽章和连续打卡,让存钱这件事不那么无聊。
去年陆续有用户问鸿蒙能不能用,我看了看 HarmonyOS NEXT 的应用数量,存钱工具类几乎是空白,就动手做了。目前鸿蒙版已经上架华为应用市场(应用 ID:6758853486),迭代到 1.9 版本。
这篇文章主要聊从 Swift + SwiftUI 到 ArkTS + ArkUI 适配过程中的技术细节和踩坑记录。
持久化方案:从 Preferences 到 relationalStore
iOS 版我用 SwiftData 做持久化,Goal 和 Transaction 是一对多关系,@Query配合FetchDescriptor查询很方便。
到鸿蒙这边,一开始我试了@ohos.data.preferences,想着 KV 存储足够轻量。结果当我造了 50 个 Goal、每个 Goal 挂 30 条 Transaction 的测试数据后,读取一次全量数据要将近 800ms,而且按状态筛选、按时间排序这种需求得全部读进内存再手动过滤,写起来又丑又慢。果断切到了@ohos.data.relationalStore建关系型表。
核心建表逻辑大概是这样:
const SQL_CREATE_GOALS = `CREATE TABLE IF NOT EXISTS goals ( id TEXT PRIMARY KEY, name TEXT NOT NULL, icon TEXT DEFAULT 'S', mode TEXT CHECK(mode IN ('wish','free')) DEFAULT 'wish', status TEXT CHECK(status IN ('active','archived')) DEFAULT 'active', strategy TEXT DEFAULT 'weekly', target_amount INTEGER DEFAULT 0, current_amount INTEGER DEFAULT 0, plan_amount INTEGER DEFAULT 0, total_periods INTEGER DEFAULT 0, created_at INTEGER NOT NULL, next_due_date INTEGER )`; ``` 所有金额字段都是 `INTEGER`,单位是"分"。这就引出了移植过程中最蠢的一个 bug。 ## number 精度丢失:标题里说的两天就花在这儿 iOS 版里 `targetAmount` 用的 `Int64`,整数运算没有精度问题。移植到 ArkTS 的时候我图省事直接用了 `number` 类型存"元": ```arkts // 错误写法 —— 别学我 let currentAmount: number = 1999.99 let deposit: number = 0.01 currentAmount += deposit // 期望 2000.00,实际可能是 1999.9999999999998这个问题在小金额测试的时候完全看不出来。我是用稍大的数做集成测试时才发现进度条百分比算出来不对——比如目标 2000 元、已存 2000 元,进度条显示 99.99%。
排查过程说实话挺折腾的。一开始我怀疑是relationalStore的resultSet.getDouble()返回值有精度截断,花了大半天在数据库读写层打日志,确认存进去的数和读出来的数是一致的。然后又怀疑是 ArkUI 的Progress组件渲染有 bug,用Text直接展示百分比值才发现——数据库里的数是对的,但在 TS 层做多笔存款累加后值就漂了。经典的 IEEE 754 浮点问题,说出来谁都懂,但定位的时候真的很难第一时间想到,因为 Swift 端从来没出过这个事。
解决方案很朴素:所有金额统一用"分"为单位存整数,显示的时候除以 100 格式化。数据库 schema 里全部用INTEGER,ArkTS 层做一次(amount / 100).toFixed(2)。改完之后再也没出现过精度问题。
成就徽章系统:currentStreak 的计算
iOS 版有一套我挺喜欢的成就系统。BadgeLibrary里定义了十几个徽章,比如streak_7(连续打卡 7 天)、night_owl(夜间存款 10 次)、collector(同时维护 5 个目标)。每个徽章的解锁条件是一个闭包,拿StatsSummary做判断。
移植到 ArkTS 问题不大,箭头函数替代闭包就行。真正麻烦的是StatsSummary的计算——iOS 端用 SwiftData 的查询能力配合内存计算很顺畅,鸿蒙这边得自己写 SQL 查出原始数据,再在 TypeScript 层做二次处理。
其中最复杂的是currentStreak(当前连续打卡天数)。我的做法是用 SQL 按天去重查出所有有存款记录的日期,然后在 TS 里从今天往回倒推:
SQL 部分用created_at / 86400000做整数除法实现按天去重,比在 TS 层遍历所有记录再 groupBy 高效得多。这个 streak 值算好之后塞进StatsSummary,徽章解锁判断就很直白了。
每日提醒:notificationManager 的平台差异
iOS 用UNUserNotificationCenter注册本地通知,流程大家都很熟。鸿蒙这边用@ohos.notificationManager,有几个关键差异:
权限申请。鸿蒙的通知权限默认是关闭的,需要用notificationManager.requestEnableNotification()触发系统弹窗。但这个 API 在部分设备上行为不太一致——我在 Mate 60 上测试正常,在某款平板上弹窗死活不出来。最后加了 try-catch,如果requestEnableNotification抛异常或者超时,就弹一个自定义对话框引导用户手动跳转系统设置页。不算优雅但至少不会卡死流程。
通知渠道是必须的。iOS 可以直接发本地通知,鸿蒙需要先创建 NotificationSlot,不创建就静默失败,连报错都不给你,排查了一会儿才意识到。
我的提醒设置默认是每天 20:15 推送。这个时间点是我自己用下来觉得合适的——太早还在上班,太晚已经准备睡了。用户可以在设置里自定义reminderHour和reminderMinute。
备份稳定性测试
iOS 上线后出过一次备份导入失败的问题——某个用户的 Goal 日期字段为 nil,JSON 序列化直接挂了。吃过这个亏,鸿蒙版从一开始就写了稳定性测试脚本,核心思路是模拟大量数据验证序列化完整性:
配置了 1200 个 Goal,每个带 10 条 PlanItem 和 10 条 Transaction(共 20 条子记录),总共两万多条数据跑一遍序列化 / 反序列化。脚本里用了自定义的assert工具函数(ArkTS 没有原生 assert API,自己封装的,条件不满足直接 throw Error),检查两件事:序列化后 JSON 长度不超过 8MB、反序列化后 Goal 数量一致。备份数据结构里有个BACKUP_SCHEMA_VERSION字段,当前值是 1,以后加字段可以通过版本号做迁移而不破坏旧备份。
真实数据和商业化
实话说,鸿蒙版上线后数据很冷。最近一周下载量个位数,付费为零。
说一下付费模式:跟 iOS 版一样是订阅制,免费版可以创建 3 个储蓄目标,Pro 订阅解锁无限目标、主题切换和成就徽章系统。订阅走的华为 IAP。
我分析下载冷的原因:ASO 还没认真做,截图和关键词都很粗糙;鸿蒙纯血应用的存量用户还在增长期,工具类 App 的自然流量本来就不大。但我翻了华为应用市场"存钱""储蓄"关键词下的搜索结果,原生鸿蒙应用确实很少,大部分还是安卓套壳。我觉得只要产品做到位,后面增长应该能起来。
没做的事:硬币掉落动画
得坦白一下。iOS 版有个用 SpriteKit 做的硬币物理掉落动画,硬币之间会碰撞弹跳堆叠,配合 Haptic Feedback 触感反馈,存钱那一刻特别爽。
鸿蒙版暂时没做。HarmonyOS 目前没有 SpriteKit 这种现成的 2D 物理引擎框架,如果要做得用 Canvas 组件手写碰撞检测和物理模拟。我评估了一下大概要额外两三周,效果还不一定好。先上线核心功能,动画后面再补。有点可惜但我觉得这个取舍是对的。
下一步
近期计划:适配鸿蒙桌面卡片(Widget)显示今日存钱进度、补上深色模式主题、用 Canvas 实现一版简化的硬币物理动画。
Canvas 物理引擎这块我打算单独写一篇,会包括碰撞检测算法、帧率控制和 ArkUI Canvas 的性能调优。如果方案跑通了,碰撞检测模块会开源出来。对这个方向感兴趣的可以先关注,下篇发出来第一时间能看到。
另外如果你在鸿蒙上做过 Canvas 2D 物理模拟,或者有好用的引擎方案推荐,评论区聊聊——我真的需要。