看到“Flutter 框架跨平台鸿蒙开发”这个标题,我第一反应不是“Flutter 终于支持鸿蒙了”,而是“跨平台这个坑到底有多深”。我上一款工具类应用就是基于 Flutter 做的跨平台版本,后来要适配鸿蒙设备时才发现,框架和系统之间的适配远不是装个 SDK 那么简单。这篇就围绕标题里的“手账便签纸收藏应用”来聊,说说我在实践中的取舍、踩坑与最终落地方案,希望能给正在纠结“要不要用 Flutter 搞鸿蒙”的朋友一些参考。
这个应用本身并不复杂:手账爱好者需要一个能快速记录灵感、收藏好看便签纸、打标签分类的工具。但它涉及的典型问题很全——本地数据持久化、图片渲染、列表刷新、组件通信、页面状态保持。换句话说,拿它当 Flutter 跨平台和鸿蒙开发的试验田,再合适不过。
1. 内容整体设计与思路拆解
1.1 手账便签纸收藏应用到底要解决什么问题
先把标题拆开看。“手账便签纸收藏应用”听起来有点文艺,实际核心需求非常朴素:用户看到一张好看的便签纸、一句有灵感的文案、或者一个手账排版范例,希望能快速存下来,将来随时翻出来参考。它和普通备忘录最大的区别是“收藏属性”,也就是要有一个明确的收藏/取消收藏动作,还要支持按分类浏览。
我在设计时把功能收敛成四条主线:新建/编辑便签、收藏与取消收藏、分类标签、搜索。前两条是刚需,后两条是让内容“存得进去也找得到”。很多同类应用死掉不是因为功能少,而是因为存了一堆东西却根本翻不到,所以我特意把搜索和分类放在和新建同级的优先级上。
这个应用的典型场景有两类:一类是手账素材收集,用户看到喜欢的排版就截图进来,附上来源链接和自己的批注;另一类是日常灵感记录,类似便利贴,随手写一句话,打上标签。两类场景合在一起,决定了数据模型不能太简单,至少要有“标题、正文、图片列表、标签、收藏标记、创建时间”这几个字段。
1.2 技术定位:Flutter 跨平台生态与鸿蒙现状
标题把“Flutter 框架、跨平台、鸿蒙”三个词放在一起,很多人会理解为“用 Flutter 开发鸿蒙应用”。真实情况要复杂一些。Flutter 官方支持的平台里并没有鸿蒙,社区确实有适配项目,比如某些基于 Flutter 的鸿蒙 fork 分支或 OpenHarmony 的引擎适配,但它们的维护节奏、API 完整度和官方 Flutter 版本之间存在明显gap。
做跨平台开发的人都清楚,Flutter 的核心价值是“一份 Dart 代码,多端渲染”。它自带 Skia/Impeller 渲染引擎,UI 不依赖系统组件,所以理论上只要能把引擎搬到目标平台上,就可以运行。鸿蒙的问题恰恰在于:它不是 Flutter 官方工程里默认的 target platform,你要么用社区维护的 fork 仓库重新编译引擎,要么用混合方案搭桥。这带来的不确定性,在真正接手一个产品时是要命的。
我在项目启动前做了一个小验证:拉一个最简单的 Flutter 页面,用社区方案编译到鸿蒙设备上跑。结果页面能起来,但一用到 PlatformView、EventChannel 这类原生交互功能,版本匹配问题就暴露了。由此我得出一个结论:如果你的应用只是纯 UI 展示,Flutter 迁移鸿蒙可行;只要涉及系统能力、文件读取、媒体库,就必须先确认适配分支支持到什么程度。
1.3 一个容易被忽视的问题:引擎版本与渲染架构
很多教程不会提 Flutter 版本差异对鸿蒙适配的影响,但实际开发中这就是最大的暗坑。比如 Flutter 3.x 后 Impeller 在 iOS 上逐步取代 Skia 作为默认渲染引擎,渲染性能和稳定性有所变化。社区做鸿蒙适配时,往往是跟着某个特定 Flutter 版本走的,你想升级 Flutter 小版本可能就得等适配分支同步。更麻烦的是,Dart 语言、Flutter Framework、Flutter Engine 三者的版本必须严格对应,其中任何一环错位,编译期不一定报错,运行期才崩溃。
我在实践中是反过来做的:先确定鸿蒙适配分支锁定的 Flutter 版本,再围绕这个版本写业务代码,而不是先写代码再往上怼引擎。这样虽然牺牲了一点 Flutter 新语法,但换来了可预期性。对一个小工具应用来说,稳定比炫技重要得多。
2. 两条技术路线,怎么选不后悔
2.1 路线 A:用 Flutter 直接编译到鸿蒙
如果你已经有 Flutter 应用,而且业务相对简单,那路线A是合理的。你需要做的事包括:把鸿蒙适配的 Flutter SDK 装好、用对应的引擎重新编译产物、处理原有第三方插件在鸿蒙上的缺失、重写获取图片和文件存储等原生能力。这里我建议先做一次插件清单梳理,把所有依赖插件的鸿蒙支持状态列出来,再决定要不要走这条路。
我见过一个典型的失败案例:团队花了三天让 Flutter 页面跑上鸿蒙,结果发现原本用于选图的 image_picker 插件没有鸿蒙实现,只好自己写原生插件,又折腾一周。所以路线A真正考验的不是 Flutter 能力,而是你对鸿蒙原生 API 的熟悉程度。如果核心功能都要通过自己写插件补,Flutter 的跨平台优势就只剩 UI 层了,这时就要重新算一笔账。
2.2 路线 B:ArkTS 原生重写核心模块
鸿蒙自己的 UI 框架是 ArkUI,开发语言是 ArkTS。对新手来说,从 Flutter 切到 ArkTS 有一个适应成本,但并没有想象中高。ArkUI 的声明式写法、组件状态管理、生命周期模型和 Flutter 在很多地方神似,比如@State 对应 Flutter 里 setState 后的重建逻辑,@Prop 和 @Link 对应父子组件传参和双向绑定。我上手两天后就能写出像样的页面了。
路线B还有一个隐藏优势:鸿蒙设备上的系统能力调用,比如文件读取、媒体库、权限申请,都是现成的一等公民 API。做手账应用最核心的“选图、存图、读图”功能,在原生生 态里反而是成本最低的。Flutter 版反而要绕道插件,插件还得等适配。
2.3 不同场景下的取舍逻辑
我的建议很简单:存量 Flutter 应用且功能密度低,走 A 路线做技术验证;从零开发且目标平台以鸿蒙为主,直接上 B 路线。标题里的“跨平台”真正合理的设计是分层:业务模型和数据结构尽量跨平台通用,UI 层在各端分别实现,而不是字面意义的“一套代码跑到底”。
拿手账应用来说,我最后采取了折中方案:数据层用 Dart 先写好,再用 ArkTS 重写一遍核心逻辑,UI 层完全用 ArkUI 实现。这样既不浪费跨平台的数据建模思路,又避免把命运押在 Flutter 的鸿蒙适配进程上。如果你对“一次代码走天下”有执念,建议做了原型测试后再决定,不要一开始就赌。
3. 数据模型与本地存储:手账应用的“地基”
3.1 设计便签数据模型时别把它当成普通 TODO
手账便签和普通待办事项最大的区别在于:内容形态多样,可能是纯文字、一张图、也可能是图文混排。所以数据模型不能只存 title 和 content 两个字符串。我在建表时加了 images 字段,用来存图片路径的 JSON 数组;加了 category 字段存放分类名称;加了 is_fav 布尔字段标记收藏状态;加了 ts 时间戳用来排序和搜索。这个表结构简单,但覆盖了应用的全部核心场景。
建表语句用 SQLite 语义:
CREATE TABLE IF NOT EXISTS notes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT, content TEXT, images TEXT, category TEXT, is_fav INTEGER DEFAULT 0, ts INTEGER );ArkTS 里用的是鸿蒙的 RelationalStore,它的 SQL 语法和 SQLite 基本一致,从 Flutter 迁移过来的开发者几乎不用改思路。这里有一个容易被忽略的点:is_fav 不要用布尔类型,建议用 0/1 整数。原因很简单,后续你想扩展收藏分组、收藏时间、置顶状态时,整数类型可以平滑演进,布尔类型只能推倒重来。
3.2 RelationalStore 还是 Preferences
鸿蒙本地存储有两个高频选择:Preferences 适合存键值对,比如用户设置、最近搜索词;RelationalStore 适合存结构化数据,比如便签记录、分类表。很多新手图省事,把所有数据全塞 Preferences,单个 key 里塞一长串 JSON,结果应用一上量就卡。手账应用的数据量虽然不算大,但每篇便签可能有图片、大段文字、多次编辑记录,放在关系型数据库里更适合后续做查询和分页。
我在实际项目里的分工是:便签数据走 RelationalStore,用户偏好和主题设置走 Preferences。两者混用并不冲突,反而能让各自的查询压力降到最低。给刚入门的朋友一个建议:只有一条记录要存时用 Preferences,一旦出现“列表页”、“筛选”、“按时间倒序”这些动词,就直接上 RelationalStore,别犹豫。
3.3 便签纸的“纸感”渲染思路
手账应用如果只是白底黑字,就失去了“便签纸”的味道。但这里我不想直接引用大截图,因为会让安装包体积暴涨。更好的做法是动态绘制便签纸背景:普通便签画横线,手账页画网格,收藏页画点阵。需要说明的是,这一步在 Flutter 里可以用 CustomPaint 轻松实现,在 ArkUI 里则要改用 Canvas 组件。虽然渲染 API 不同,但画横线网格的逻辑是一致的。
Canvas 绘制便签纸网格的伪代码大致如下:
// ArkTS 中获取 Canvas 上下文 private drawGrid(context: CanvasRenderingContext2D) { const spacing = 24; const width = this.displayWidth; const height = this.displayHeight; context.strokeStyle = '#E8E4D8'; context.lineWidth = 0.5; for (let x = 0; x <= width; x += spacing) { context.beginPath(); context.moveTo(x, 0); context.lineTo(x, height); context.stroke(); } // 横线同理 }这个方案能明显降低包体积,同时给用户一种“这真的是便签纸”的视觉暗示。如果你非要加厚的纸张纹理,也可以放一张小尺寸无缝纹理图重复平铺,但要注意图片资源的内存占用。鸿蒙开发里图像解码性能不如成熟方案,纹理图过大很容易在低端设备上卡动画。
4. 实操过程与核心功能实现
4.1 搭建 ArkTS 工程与最小权限配置
新建 DevEco Studio 工程时,建议选择 Empty Ability 模板,不需要那些复杂的多模块模板。模块结构上,我用了一个 Entry 模块承载全部 UI,一个 Shared 模块放置工具类和数据模型,这样后续如果真要接 Flutter 或其他端,业务逻辑可以被复用。工程建完后,第一步要做的就是配置存储和媒体权限。
module.json5 里最小权限配置如下:
{ "requestPermissions": [ { "name": "ohos.permission.READ_IMAGEVIDEO" }, { "name": "ohos.permission.WRITE_IMAGEVIDEO" }, { "name": "ohos.permission.INTERNET" } ] }INTERNET 权限在你不需要网络请求时可以不申请,但手账收藏大概率要同步图片来源链接,所以我直接加上。权限申请的时机建议放在用户第一次点“选择图片”时,不要一进来就弹窗,否则容易触发隐私合规问题。真机上调试时,很多问是因为没在设置里手动允许媒体访问导致的,这不算 bug,但排查起来很烦人。
4.2 便签收藏功能与列表页代码骨架
收藏功能的关键不只是把 is_fav 字段改成 1,还要考虑“收藏列表”与“全部列表”的数据刷新一致性。我在实现时用一个简单的状态提升方案:首页和收藏页都监听同一个数据源变化回调,数据变更后重新查询数据库,再统一更新列表。下面是收藏列表页的核心骨架:
@Entry @Component struct FavoriteListPage { @State notes: NoteItem[] = []; private dbHelper: RdbHelper = new RdbHelper(); aboutToAppear(): void { this.loadFavorites(); } async loadFavorites(): Promise<void> { const result = await this.dbHelper.queryNotes("is_fav = 1 ORDER BY ts DESC"); this.notes = result; } build() { Column() { List({ space: 12 }) { ForEach(this.notes, (item: NoteItem) => { ListItem() { NoteCard({ note: item }) .onClick(() => { router.pushUrl({ url: 'pages/NoteDetailPage', params: { id: item.id } as Record<string, string> }); }) } }, (item: NoteItem) => item.id.toString()) } .width('100%') .layoutWeight(1) } } }这段代码体现了 ArkUI 的几个关键点:aboutToAppear 相当于页面每次显示前的加载时机,适合在这里刷新列表;router.pushUrl 传参时需要强转 Record 类型,不然编译期不报错但运行时取不到参数。我在实际调试时被这个类型坑过一次,页面跳转是成功的,参数拿到的却是 undefined,排查半天才定位到是参数格式问题。
4.3 分类、搜索与下拉刷新
分类和搜索在数据库层面都是 WHERE 子句的变体。分类是 field = 某个值和 is_fav 组合,搜索是 LIKE 查询。这里要注意一点:字符串 LIKE 查询在数据量小的时候感觉不到差距,但一旦便签数量超过几百条,中文模糊查询的效率会明显下降,建议后续引入倒排索引或分词方案。对小工具应用来说,当前阶段用 LIKE 加时间倒序就够了,架构上留好接口即可。
下拉刷新在 ArkUI 里建议用 Refresh 组件包住 List,注意刷新回调里要等数据彻底查完再结束 loading。在 Flutter 里改写的话就是 RefreshIndicator 加 onRefresh 回调,思路完全一致。我在实践里发现一个细节:刷新结束后列表会闪一下,这是因为重新查询数据库后直接把整个数组赋值给了 @State,导致所有行都重建。精益做法是只更新有变化的数据项,或者给列表项加上稳定 id,让 ArkUI 的 diff 算法少做无用功。
5. 常见问题与排查技巧实录
5.1 便签纸图片在高分屏下走样
图片走样是我在这个项目里第一个遇到的视觉问题。原因很简单,没有做屏幕密度适配。鸿蒙设备屏幕拥有多种分辨率,我在 Flutter 里可以用 MediaQuery 动态计算尺寸,在 ArkUI 里要读取 display density 再对图片显示宽高做缩放。搜索热词里经常能看到“Flutter PlatformView 适配”,本质也是跨端视图在不同像素密度下的尺寸不一致问题。
解决方法是在加载图片前先读取目标容器的实际像素大小,再按比例缩放图片。如果懒得动态计算,直接把 image 组件的 objectFit 设置为 Cover,也能避免明显变形,但会裁掉图片边缘内容,收藏素材图时未必能接受。我更建议存图时就把路径和宽高信息一并存进 images 字段,显示时直接用预存的宽高比拟合。
5.2 页面切换后状态丢失
很多 Flutter 开发者都搜过“flutter navigator 切换页面后,会丢失状态吗”,因为 Flutter 的默认路由行为并不会自动保留所有页面状态。鸿蒙的 router.pushUrl 也有类似属性:页面退到后台再返回时,默认重新触发 aboutToAppear,如果这里没有重新拉数据,你刚才在详情页做的收藏动作往往不会反映到列表页。
我在项目里的解决方法是:详情页每次返回时,把需要同步的字段作为 callback 传给列表页,或者更简单一点,在列表页的 aboutToAppear 里无条件重新查询一次数据库。对这个数据规模的应用来说,重新查询的性能开销完全可以接受。不要迷信页面级状态缓存,躬亲重查一遍数据库比任何缓存策略都稳。
5.3 EventChannel 与原生通信的调试要点
如果你还是在 Flutter 路线里做混合开发,一定会碰到 Flutter EventChannel。热词里也有人问“flutter 组件通信”,其实 EventChannel 适合原生向 Flutter 单向推送事件流,比如系统媒体状态变化、网络状态变化;MethodChannel 适合 Flutter 主动调原生方法并拿结果。很多人一上来就把两者用反,结果通信时序乱成一锅粥。
我在调试 EventChannel 时吃过一个暗亏:需要在原生侧先注册通道、再往 Flutter 发事件,如果 Flutter 侧的 listener 注册时机太晚,第一条事件就会被吞掉。解决办法是实现一个带缓存的 EventChannel 包装类,原生侧在 Flutter 注册完成前把事件先存到一个队列,一旦注册成功立即补发。这个坑在官方文档里几乎找不到,但实际开发中必然会碰到。
5.4 小问题速查表
| 现象 | 可能原因 | 解决思路 |
|---|---|---|
| 列表页返回后不显示最新收藏 | 页面没刷新数据 | aboutToAppear 里重查数据库 |
| 图片加载后颜色偏灰 | 图片路径写错或权限没弹 | 检查媒体库权限和文件路径 |
| 收藏数量不对 | is_fav 更新后没同步缓存 | 统一走数据更新回调 |
| 下拉刷新一直转圈 | Refresh 回调没及时结束 | 确认查询完成后关闭 loading |
| 页面跳转参数 undefined | 参数类型不是 Record | 强制转换参数对象类型 |
| 便签纸网格线发虚 | 没有做屏幕密度适配 | 根据 density 调整线宽 |
这张表是我把开发中真实遇到的高频问题整理出来的,贴给后面接手的人能少走一半弯路。
6. 写在最后的个人心得
我个人的实际体感是:Flutter 跨平台开发理念很棒,尤其适合快速验证 UI 和做原型,但面对鸿蒙这种非官方目标平台时,一定要多留一个心眼。所谓“跨平台鸿蒙开发”,更务实的玩法是把业务逻辑抽象好,UI 层再根据平台打磨,而不是死守一套代码。
这个手账便签纸收藏应用目前还只是单机版,但我已经想好了后续扩展的两条路:一是加 WebDAV 同步,让素材在手机、平板、电脑之间流转;二是加更多纸张模板,以插件形式下载,把“便签纸收藏”从单纯的素材管理变成模板商店。后面这两块如果再动手,我还会回来继续写续篇。