鸿蒙轻量记账应用开发实战:ArkUI与分布式数据同步

发布时间:2026/10/4 3:17:13
鸿蒙轻量记账应用开发实战:ArkUI与分布式数据同步 做记账工具这事的起因特别朴素家里开销太杂房贷、水电、孩子的培训班、日常买菜月底看账单总是一笔糊涂账。市面上的记账App功能确实多但越用越觉得臃肿启动慢、广告多、还要登录账号记一笔账要操作好几步。后来我换了鸿蒙生态的设备发现一个有意思的方向鸿蒙的分布式数据管理让数据天然可以在手机、平板之间流转那为什么不自己写一个轻量记账工具把账目数据直接放在分布式数据库里于是就有了这篇文章。这篇博文完整记录了我从零开发一款基于鸿蒙生态的轻量化记账工具的全过程界面层完全用 ArkUI 组件手写涵盖 Tabs 导航、Flex 与 RelativeContainer 布局、列表与表单交互数据层则借助分布式数据管理让账目在手机和平板之间自动同步不需要搭服务器、不用注册账号。内容偏实操代码和步骤我都会贴出来如果你准备入坑鸿蒙应用开发或者想把一个轻量应用搬到鸿蒙生态上可以直接参考这套思路。1. 为什么是鸿蒙生态 轻量记账——技术选型背后的思考1.1 轻量记账的“轻”到底指什么在动手写代码之前我花了很多时间想一个问题记账工具的核心价值是什么不是功能多而是“记得快、查得清、数据不丢”。市面上的产品往往在“功能多”这条路上走得太远预算管理、多人分账、投资理财、社区分享功能一个接一个反而把最基础的“几秒钟记一笔账”这个体验给稀释了。所以我把产品边界收得很窄只做两件事——流水记录和月度统计。一个页面用来快速记账一个页面用列表展示账目流水一个页面做月度汇总。整个 App 的目标就是让用户从打开到记完一笔账操作路径不超过三次点击。这种克制直接影响了技术选型我不需要引入复杂的第三方框架鸿蒙本身的 ArkUI 声明式组件和内置状态管理已经完全够用。轻量化还有一个隐含要求安装包不能大、内存占用不能高。我给自己定的目标是安装包控制在 10MB 以内这个目标在后来开发中确实有指导意义。很多依赖能砍就砍最终打出来的 HAP 包大约 6MB在各类同类工具里算是非常轻的了。1.2 选型对比ArkUI、WebView 还是 Flutter调研阶段我把几条路线都过了一遍列成表格对比会更直观方案开发效率多端复用与鸿蒙分布式能力结合包体积我的结论纯 ArkUI 原生开发高仅鸿蒙生态原生支持最顺畅小最终选择WebView H5中等Web 端可复用需要桥接能力受限中放弃Flutter 跨端中偏高多端复用需要插件桥接同步链路复杂较大放弃ArkUI 最吸引我的不是它的 UI 写法而是它和鸿蒙系统能力是同一套体系。分布式数据管理要真正生效需要系统底层提供设备发现、连接管理和数据同步回调这些能力 WebView 或者 Flutter 都很难直接拿到就算通过桥接拿到了也会引入大量胶水代码反而把轻量化搞复杂了。另外 ArkUI 的声明式写法有个明显好处界面状态和业务数据之间的绑定是响应式的。账目列表增删时UI 会自动刷新我不用写一大堆 findViewById 和 setText 这种命令式操作。对“轻量”这个目标来说这种开发体验非常友好。1.3 分布式数据管理解决的到底是哪个痛点传统记账 App 的多端同步基本靠云服务器客户端把数据上传到云端另一个设备再从云端拉取。这个方案的问题在于需要注册账号、需要联网、有传输延迟而且自己的财务数据要经过第三方服务器心理上总觉得不踏实。鸿蒙的分布式数据管理不一样。它把“同一个账号体系下的多台设备”组成了一个逻辑上的整体数据写入本地数据库后系统会自动同步到同组内的其他设备。我的体验是手机记录一笔开销平板上的账目列表过一会儿自动更新了整个过程没有登录任何云服务数据优先在设备间直接传输隐私性更好也省去了服务器成本。当然它也不是没有代价。分布式数据管理不适合海量数据场景它的定位是“跨设备流转轻量数据”。像记账这种每天产生几十条记录的个人数据量级简直是量身定做。这一点我在后面实现时体会特别深——当我把同步机制跑通的那一瞬间确实有“这技术方向选对了”的感觉。2. ArkUI 界面搭建——从项目骨架到组件实战2.1 创建工程与项目结构开发鸿蒙应用IDE 用的是 DevEco Studio我自己用的是较新版本对应 API 18 上下已经编译过几个项目稳定性没问题。新建项目时选择 Empty Ability 模板语言类型会自动配置为 ArkTS这就是 ArkUI 的应用开发语言。创建完项目之后核心代码都在entry/src/main/ets目录下我按功能拆分了几个文件entry/src/main/ets/ ├── entryability/ │ └── EntryAbility.ets # 应用入口 ├── pages/ │ ├── Index.ets # 主页面容纳底部导航 │ ├── HomePage.ets # 记一笔的首页 │ ├── DetailPage.ets # 流水明细页 │ └── StatsPage.ets # 月度统计页 ├── model/ │ └── AccountRecord.ets # 账目数据模型 └── database/ └── AccountDB.ets # 数据库封装把页面拆成四个模块不只为了代码好看更重要的是 ArkUI 的页面管理方式要求每个页面独立成组件。拆开之后各自维护状态后续无论是改布局还是调整逻辑都不会影响其他页面。这也是新手比较容易忽略的ArkUI 里页面和组件本质上都是Component早点养成组件化思维后面写复杂界面时才不会乱。2.2 Tabs 底部导航三个页签的正确姿势记账工具的导航结构很简单首页记一笔、明细、统计。ArkUI 里做底部导航最直接的方式是用Tabs组件包裹三个TabContent。这里有几个细节值得注意都是实测踩出来的。第一Tabs的barPosition要设置成BarPosition.End标签栏才会在底部。我第一次写的时候忘了设置默认跑到了顶部还以为是模板的问题。第二TabContent的tabBar属性有两种写法只写文字样式会很单调建议用自定义 builder 做图标加文字的样式选中和未选中切换两种颜色交互反馈明显更好。第三如果不想让用户在三个页签之间通过左右滑动切换记得关掉scrollable(false)让记账页保持稳定。核心代码大致是这样Entry Component struct Index { State currentIndex: number 0 private controller: TabsController new TabsController() Builder tabBuilder(index: number, title: string, normalIcon: Resource, selectedIcon: Resource) { Column({ space: 4 }) { Image(this.currentIndex index ? selectedIcon : normalIcon) .width(24) .height(24) Text(title) .fontSize(12) .fontColor(this.currentIndex index ? #FF4D4F : #999999) } .width(100%) .justifyContent(FlexAlign.Center) } build() { Tabs({ barPosition: BarPosition.End, controller: this.controller }) { TabContent() { HomePage() }.tabBar(this.tabBuilder(0, 记一笔, $r(app.media.ic_add_normal), $r(app.media.ic_add_selected))) TabContent() { DetailPage() }.tabBar(this.tabBuilder(1, 明细, $r(app.media.ic_list_normal), $r(app.media.ic_list_selected))) TabContent() { StatsPage() }.tabBar(this.tabBuilder(2, 统计, $r(app.media.ic_stats_normal), $r(app.media.ic_stats_selected))) } .scrollable(false) .barHeight(56) .onChange((index: number) { this.currentIndex index }) } }这里用onChange监听当前选中的 tab并把它赋值给State currentIndex再在tabBuilder里根据currentIndex切换图标和文字颜色。这种写法是 ArkUI 里比较典型的“状态驱动样式”思路不用手动刷新 UI状态一变组件自动重新渲染。你如果在别的页面也遇到类似联动需求记住这条规律就够了所有动态样式都交给State去驱动。2.3 记账页布局RelativeContainer 与 Flex 的组合应用记账页是用户打开 App 第一眼看到的界面布局不能乱。我的设计是顶部一个金额输入区中间是分类选择底部是备注和保存按钮。这里要重点聊RelativeContainer。它是相对定位布局子组件通过alignRules定义自己和父容器或其他兄弟组件的相对关系。用过 CSS 绝对定位的人会觉得很熟悉区别在于 ArkUI 不允许子组件同时设置对齐规则和宽高属性这一点特别容易踩坑。我一开始给金额输入框同时设置了alignRules和对width、height的直接赋值运行后界面直接错乱一部分控件飞到屏幕外去了。正确做法是把尺寸放进对齐规则里或者通过constraintSize间接控制。一个完整的金额展示区域可以这样写RelativeContainer() { Text(本月支出) .id(monthLabel) .fontSize(14) .fontColor(#666666) .alignRules({ top: { anchor: __container__, align: VerticalAlign.Top }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ top: 24, left: 20 }) Text(¥ 0.00) .id(amountText) .fontSize(40) .fontWeight(FontWeight.Bold) .fontColor(#FF4D4F) .alignRules({ top: { anchor: monthLabel, align: VerticalAlign.Bottom }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ top: 12, left: 20 }) } .width(100%) .height(160).id()方法是给组件一个名字后续其他组件可以通过这个名字做相对定位比如金额文本锚定在月份标签的下方。要注意__container__是一个保留关键字表示父容器本身用它做锚点是最常用的方式。这里的核心思想是不要用绝对坐标去计算位置而是让组件之间的相对关系去决定位置这样不管屏幕尺寸怎么变布局都不会散。分类选择需要一行放多个按钮我用的是Flex包裹的网格。Flex非常适合这种固定子项数量的场景配合wrap(FlexWrap.Wrap)可以自动换行。每个分类按钮是带图标的圆形背景点击后高亮代码结构大致是Flex({ wrap: FlexWrap.Wrap, justifyContent: FlexAlign.Start }) { ForEach(this.categories, (item: CategoryItem) { Column({ space: 6 }) { Text(item.icon).fontSize(28) Text(item.name).fontSize(12).fontColor(#333333) } .width(60) .height(70) .justifyContent(FlexAlign.Center) .borderRadius(12) .backgroundColor(this.selectedCategory item.id ? #FFF1F0 : #F5F5F5) .onClick(() { this.selectedCategory item.id this.selectedCategoryName item.name }) }, (item: CategoryItem) item.id) } .width(100%)需要注意ForEach的第三个参数——键值生成器。我传了item.id作为唯一标识实测下来这个参数不能省。如果不传当列表数据变化时ArkUI 可能无法精确复用组件导致列表项状态错乱比如选中了 A 分类刷新后高亮却跑到了 B 分类上。2.4 明细页列表List 虚拟滚动与分组展示明细页要解决两个问题按时间分组展示流水以及顶部展示本月总支出。ArkUI 里做长列表第一选择是ListListItem而不是Scroll包Column因为List有虚拟滚动能力几百条甚至几千条数据不会卡顿Scroll则会一次性把所有组件实例化。分组展示我采用了“日期标题 若干条记录”的结构。数据层先把明细按日期分组UI 层再用ForEach遍历分组结构。每个记录项用RowColumn组合左侧是分类图标中间是分类名和备注右侧是金额和日期。List({ space: 12 }) { ForEach(this.groupedRecords, (group: GroupedRecord) { ListItemGroup() { ListItem() { Text(group.date) .fontSize(13) .fontColor(#999999) .margin({ top: 12, bottom: 6 }) } ForEach(group.records, (record: AccountRecord) { ListItem() { RecordItem({ record: record }) } }, (record: AccountRecord) record.id) } }, (group: GroupedRecord) group.date) } .width(100%) .layoutWeight(1)ListItemGroup是列表分组的关键组件它比普通ListItem多了一级分组语义视觉上可以统一设置间距。这里有个细节金额数字建议统一保留两位小数并且在显示时不要省略末尾的 0。否则长列表快速滚动时数字的宽度会频繁变化视觉上看起来像在“跳”体验很差。3. 分布式数据管理——多端账目同步的核心实现3.1 数据库选型关系型还是键值型鸿蒙的分布式数据管理API 层主要分两块分布式键值数据库distributedKVStore和分布式关系型数据库relationalStore。我在项目里最终选了关系型原因很简单记账数据是结构化数据有明确字段——金额、分类、备注、时间而且我要做月度统计需要聚合查询。如果全部数据都放在键值库里这些聚合逻辑就得自己在内存里跑不仅效率低代码也会越写越臃肿。但键值库也并非没用我把一些轻量配置数据例如默认分类、首启动标记等放进了分布式键值库读写就是一条put的 API非常方便。两个库在项目里各司其职关系型数据库管流水键值库管配置。这里特别提醒一下无论是关系型还是键值型要支持分布式同步创建时就得指定正确的安全级别。关系型数据库的建库配置里有一个securityLevel必须设置成S1或以上否则后面创建分布式表时会直接报错这一点非常容易忽略。3.2 数据表设计与数据库封装账目流水表的设计不用复杂我的字段如下字段类型说明idTEXT主键UUID生成category_idTEXT分类IDcategory_nameTEXT分类名称冗余存储amountREAL金额单位元remarkTEXT备注record_timeINTEGER记录时间戳毫秒typeINTEGER0支出1收入建库逻辑我封装在了工具类里import { relationalStore } from kit.ArkData const DB_NAME account.db const TABLE_NAME record export async function initDatabase(context: Context): PromiserelationalStore.RdbStore { const config: relationalStore.StoreConfig { name: DB_NAME, securityLevel: relationalStore.SecurityLevel.S1 } const store await relationalStore.getRdbStore(context, config) await store.executeSql( CREATE TABLE IF NOT EXISTS ${TABLE_NAME} ( id TEXT PRIMARY KEY, category_id TEXT NOT NULL, category_name TEXT NOT NULL, amount REAL NOT NULL, remark TEXT, record_time INTEGER NOT NULL, type INTEGER NOT NULL DEFAULT 0 ) ) return store }securityLevel这个配置特别容易漏漏掉之后后面设置分布式表会报错。我当时忽略了它第一版同步功能怎么都起不来查了半天日志才发现是安全级别不符合系统要求。建议你在建库时就把这个参数当成固定配置刻在脑子里。真正启用分布式能力的关键操作是把普通表设为分布式表await store.executeSql( CREATE DISTRIBUTED TABLE IF NOT EXISTS ${TABLE_NAME} AS SELECT * FROM ${TABLE_NAME} )注意不能直接在建表语句里写CREATE DISTRIBUTED TABLE需要先建普通表再通过AS SELECT的方式创建分布式表。这是官方推荐的迁移路径直接建分布式表在很多系统版本上并不支持。3.3 插入账目与监听远端变更插入一条账目本身不复杂关键在于把插入动作和 UI 状态联动。我的做法是AccountDB封装一个insertRecord方法页面调用后返回并更新页面的State数据源。export async function insertRecord(store: relationalStore.RdbStore, record: AccountRecord): Promisevoid { const values new relationalStore.ValuesBucket() values.id record.id values.category_id record.categoryId values.category_name record.categoryName values.amount record.amount values.remark record.remark values.record_time record.recordTime values.type record.type await store.insert(TABLE_NAME, values) }真正的分布式同步并不需要你为插入操作额外写同步代码。系统会在底层自动把数据同步到同组成员设备。你需要做的是在 UI 层监听远端变化并刷新数据。关系型数据库提供了registerStoreObserver接口store.registerStoreObserver([TABLE_NAME], async () { const records await queryAllRecords(store) if (this.onDataChanged) { this.onDataChanged(records) } })实测中我发现这个回调在设备分布式组网状态变化时也可能被触发所以不要在回调里做过于重的 UI 刷新逻辑。我加了个简单的防抖200 毫秒内重复回调只刷新一次避免在多设备场景下收到一连串回调引起的多次渲染。3.4 在线状态、同步时机与冲突处理分布式同步有一个绕不开的问题设备之间的网络不是恒定的。手机在户外平板在家两台设备可能几小时甚至几天都无法直接组网。这种情况下数据会先留在本地数据库等设备重新建立连接后由系统自动补同步。对于记账工具来说这个机制已经足够。但你需要给用户一个“同步状态”的反馈否则用户会一直担心平板上的数据到底更新了没有。我的做法是在明细页顶部放一个状态条通过分布式数据管理提供的设备在线状态回调显示“已同步”或“等待同步”。相关 API 主要是监听组网设备的在线/离线事件再配合一个本地状态字段控制文案展示实现难度不大但很提升信任感。至于冲突处理记账场景大多是用户在不同设备上新增记录很少会同时修改同一条记录的同一字段。我采用“保留最新时间戳”的策略插入和更新时都检查record_time如果远端记录的时间戳比本地新就用远端数据。这种策略虽然简单但对个人记账这个场景完全够用完全没有必要引入复杂的一致性协议。4. 从开发到真机——调试、部署与性能调优4.1 模拟器与真机调试的前期准备开发鸿蒙应用DevEco Studio 是绕不开的工具。自己做项目练手的话用社区版就行功能上不受限。首次创建工程会下载 SDK 和工具链国内网络环境下速度还算可以不过 SDK 完整下载一般需要几分钟建议找个稳定的网络环境一次性下完。模拟器方面DevEco 内置的模拟器支持在 PC 上运行鸿蒙系统调试 UI 布局完全够用。但我很快发现了模拟器的局限分布式数据管理能力在模拟器上表现不完整多设备组网、跨设备同步这些场景模拟器很难真实模拟出网络切换和延迟。所以分布式功能的联调我基本是在两台真机上完成的。这一点要提前做好心理准备别指望一个模拟器搞定所有事。4.2 真机连接、无线调试与签名问题说到真机调试有两个操作必须掌握开启开发者模式并信任调试证书以及无线调试。无线调试打开后IDE 可以通过局域网连接手机省去了频繁插拔数据线的麻烦对日常开发效率提升非常明显。实操上要注意几个细节手机和电脑必须在同一个局域网内打开无线调试后DevEco Studio 的设备列表会自动发现设备如果发现不了先检查手机端开发者选项里的调试开关是否正常开启。鸿蒙开发者选项里的开关在不同系统版本上位置和名称略有差异找不到了就耐心翻一下一般都在“系统”或“开发者选项”菜单下。还有一个绕不开的是签名问题。华为设备上的调试签名默认有效期一段时间过期后安装 HAP 包会报签名错误重新在开发工具里配置一下调试证书就能解决。等签名配置完成之后点击 Run 按钮IDE 会自动构建 HAP 包并安装到设备上。第一次构建会比较慢因为要编译所有 ArkTS 代码并打包资源后续增量编译会明显加快。开发阶段建议养成“每写一个模块就真机跑一次”的习惯早发现问题早修比到最后集中联调要省力得多。4.3 列表性能优化与包体积控制记账 App 数据量虽然小但也要防着用户三五年下来积攒出上万条记录。这时候如果列表没有虚拟滚动一次性渲染几千个组件内存直接报警。前面说了用List替代Scroll这是第一步。第二步是复用列表项组件。给RecordItem组件标记Reusable滚动时列表项不会销毁而是进入复用池滚回时直接更新数据继续使用Reusable Component struct RecordItem { Prop record: AccountRecord build() { Row({ space: 12 }) { Text(this.record.categoryIcon).fontSize(24) Column({ space: 4 }) { Text(this.record.categoryName).fontSize(15).fontColor(#333333) Text(this.record.remark || 无备注).fontSize(12).fontColor(#999999) } Blank() Column({ space: 4 }) { Text(${this.record.type 0 ? - : }${this.record.amount.toFixed(2)}) .fontSize(15) .fontColor(this.record.type 0 ? #333333 : #FF4D4F) Text(this.record.timeText).fontSize(11).fontColor(#BBBBBB) } } .width(100%) .padding({ left: 16, right: 16, top: 12, bottom: 12 }) .backgroundColor(#FFFFFF) .borderRadius(12) } }实测标记Reusable之后几千条数据的列表滑动帧率能稳定在较高水准。包体积方面我优化了一件事把分类图标从 PNG 图片换成了字体文件用文字字符代替图标图标相关资源体积从几百 KB 降到了几十 KB。最终 HAP 包大约 6MB符合我一开始定的“轻量”目标。5. 常见问题排查与避坑速查5.1 双端数据不同步先查这三件事很多人在聊天中问我两台设备之间账目不同步怎么办。我的排查顺序是固定的。第一确认两台设备登录了同一个华为账号且都开启了系统级的设备协同能力。分布式数据管理依赖账号体系确认设备归属不同账号之间不同步这一点是设计如此不算 bug。第二确认应用在两端都拿到了分布式数据库的访问权限而且数据库的安全级别设置一致。如果一端是S1另一端是S2同步时会因为安全等级不匹配直接失败。这种问题最容易出现在“一台设备跑到了旧版本安装包”的场景里。第三打开日志看registerStoreObserver有没有被触发。如果回调完全没进入说明分布式组网根本没建立这时候要回到设备在线状态检查。建议在开发阶段把日志规范起来每个关键节点都打印一条带标识的日志排查同步问题时两头日志一对问题出在哪台设备上瞬间就清楚了。5.2 RelativeContainer 布局错乱的排查思路RelativeContainer有两个高频问题。第一个是子组件同时设置alignRules和宽高属性导致的布局冲突解决办法是把宽高逻辑交给对齐规则或constraintSize。第二个是margin会被计算进对齐结果里两个组件使用同一个锚点再加不同 margin可能出现意外重叠。排查这类布局问题时我的习惯是先把所有margin去掉让布局回到最简状态再逐个加回边距定位是哪一个边距导致的问题。用二分法定位比肉眼盯着代码猜要快得多。5.3 真机连不上与 API 版本适配真机连接不稳定八成是签名问题。企业级设备调试还需要在测试设备列表里手动添加设备标识别问我怎么知道的都是踩出来的。新手最容易忽略的是鸿蒙的系统 API 版本在不断变化如果你用的 SDK 版本和设备系统版本差距过大IDE 会直接拒绝安装。保持开发工具和 SDK 持续更新是省心的长期方案。另外如果遇到某个 API 提示废弃比如旧版的导入方式新版本改用了模块化导入方式尽早迁移到新写法别在旧写法上硬扛。现在的文档和示例更新都比较及时跟着官方示例走一般不会踩太大的坑。5.4 问题排查速查表现象可能原因解决思路两台设备数据不同步账号不一致、安全级别不一致统一账号统一安全级别为 S1插入数据后 UI 没更新数据源未更新插入后重新查询并赋值给 State列表滑动掉帧未使用 List 或未标记 Reusable改用 List 并复用列表项组件布局错乱或控件飞出屏幕RelativeContainer 子组件直接设宽高改用 alignRules 或 constraintSize真机安装报签名错误调试证书过期重新配置调试证书我自己在实际开发中的体会是鸿蒙这套技术栈的坡度并不平缓但也没有想象中那么陡。ArkUI 的声明式写法和前端写法有相通之处分布式数据管理则是真正让应用形态发生变化的特性。记账工具本身也许不值一提但它让我完整走通了一条链路工程搭建、界面布局、状态管理、分布式数据、真机调试、性能优化。如果你也想练手我建议从一个小而具体的工具开始不要一上来就做“大而全”的产品先在单一设备上跑通界面再加入分布式能力。这样踩坑的时候你至少知道坑在哪一层。最后再分享一个小技巧开发阶段一定要把日志规范起来每条账目操作都打印带唯一标识的日志尤其是record_time和操作类型。排查同步问题的时候双端日志一比对问题出在写入、同步还是 UI 刷新基本一分钟就能定位。这个习惯帮我省下了大量排查时间。