1. 起这个项目之前,我先说几句大实话
先说结论:Flutter 做跨平台应用,现在能正儿八经地编译到鸿蒙,而且不是那种“套个 WebView 假装原生”的搞法,是真真实实跑在鸿蒙的 ArkUI 渲染管线上。这个项目我前后花了大约三周业余时间,从搭环境到写完一个能用的书法练习 App,中间踩了不少坑,也验证了一条完整的落地方案。今天这篇文档就是把整个思路、代码结构、关键实现和踩过的坑全部摊开讲清楚。
这个应用解决什么问题呢? 练过书法的人都知道,光看帖不临帖等于没练。但很多人没条件铺开宣纸、备好笔墨天天练,尤其上班族在地铁上、午休时,能掏出来写几个字的机会少得可怜。手机和平板是最适合碎片化练字的载体。问题是:市面上的练字 App 要么只做字帖扫描,要么做成了识字游戏,真正能让你“跟着笔画一笔一笔写”并给出反馈的工具非常少。更关键的是,很多 App 只适配安卓或 iOS,想覆盖鸿蒙设备还得单独开发一套,成本高得离谱。
所以我用 Flutter 做了一套跨平台方案。核心诉求是:一套 Dart 代码,同时跑在安卓、iOS、鸿蒙上,并且针对鸿蒙做原生能力适配(比如拉起系统分享、使用系统字体、适配折叠屏和手写笔)。这套方案如果你手里正好有鸿蒙手机,可以直接跑起来;没有鸿蒙设备的话,照样可以跑在安卓和 iOS 上,开发调试流程完全不受影响。
这篇文章适合三类人来看:
- 正准备给自己的 Flutter 应用加鸿蒙支持,但不知道从哪下手的开发者;
- 想做工具类、教育类、创意类 App,但不想为每个平台重复开发的产品负责人;
- 对“跨平台 + 系统级适配”感兴趣,想了解鸿蒙到底能不能承载 Flutter 生态的移动端工程师。
后面我尽量不废话,直接上干货。从项目结构、功能拆解,到具体代码的写法、鸿蒙适配的关键配置,再到我亲手踩过的坑和排查方法,都会一一写出来。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 项目结构设计与功能路径拆解
2.1 为什么是 Flutter,而不是 uni-app 或 ArkUI 原生
说实话,在决定技术栈之前我把几个主流方案都过了一遍。ArkUI 原生开发对鸿蒙生态的适配当然是最深的,但问题在于它只服务鸿蒙,如果后续还要覆盖安卓和 iOS,等于团队要维护三套 UI 逻辑。uni-app 虽然也能一套代码多端跑,但它的性能瓶颈和复杂交互的定制难度,在书法临摹这种高频触摸绘图场景里挺吃亏的。我上一轮用 uni-app 做过一个手绘标注的小工具,频繁重绘时卡顿很明显,所以这次坚决换 Flutter。
Flutter 的好处在于它的渲染引擎完全由自己控制,不依赖系统提供的原生控件。也就是说,你画一万条笔画、做像素级的图形变换,在安卓和鸿蒙上的表现几乎是相同的。对于书法练习这种强渲染、强交互的场景,Flutter 的 CustomPaint 组件 + 自绘引擎优势非常大。
另外,鸿蒙官方也在推进 OpenHarmony SIG 对 Flutter 的适配。目前 Flutter 的 ohos 分支已经支持到较新的版本,可以打包成 HAP 安装包,也可以直接挂在鸿蒙的 DevEco Studio 工程里联调。虽然还不是官方主分支的“一等公民”,但社区的适配速度很快,日常开发已经足够顺手。
2.2 应用整体架构:从数据层到 UI 层怎么划分
我把整个应用拆成四层,跟普通的 Flutter 项目相比,多了一层“平台能力抽象层”。这一层非常关键,因为它把鸿蒙特有的能力(比如拉起系统分享、读取系统相册、使用系统字体解析)和 Flutter 通用逻辑隔离了。
- 数据层:负责字帖数据的加载、练习记录的本地存储、每日练习统计。这里我用了 sqflite 作为本地数据库,并且把字帖数据做成 JSON 资源打包进 assets,启动时装载到数据库中。为什么不用现成的云端?因为书法练习的数据具有强隐私性,用户手写的笔迹、练习记录不应该上传到服务器端,本地优先是更安全也更合理的方案。
- 业务层:包含笔画管理、临摹判定、评分引擎、练习计划调度。这一层的核心是两个模型:
CharacterModel(描述一个汉字的所有笔画信息)和PracticeRecord(记录一次练习的时间、字符、评分)。 - 平台能力抽象层:定义了
PlatformBridge接口,里面包含“读取系统字体”“拉起分享面板”“获取屏幕安全区域”等方法。在鸿蒙上我实现了一个HarmonyBridge,在安卓和 iOS 上则走默认实现。 - UI 层:主要是三个大页面:字帖列表页、临摹练习页、统计回顾页。另外还有一个全局的“今日推荐”小组件入口。
这套分层结构让整个项目保持清晰,也方便后续换掉某个底层实现而不影响业务逻辑。比如最开始我用的本地数据库,后来想加一个“云同步”能力,只需要改数据层的数据源,UI 层完全不用动。
2.3 字帖资源从哪里来,如何组织数据格式
这是项目里最麻烦的部分之一。书法字帖的数据不像普通文本,它需要记录每个汉字的笔画顺序、每个笔画的起止点坐标、笔画粗细、转折点位置等。如果手工录入,工作量会非常大。我的做法是:
- 维度一:字形数据。使用开源书法字库(例如基于“碑帖”的 SVG 路径数据),把汉字的每个笔画拆成独立路径。这一步需要借助字体解析工具,把 TTF/OTF 字体文件按笔画导出成 SVG Path。
- 维度二:书写建议数据。这就是手工补充的部分了,针对高频前 500 个汉字,我在 JSON 里标注了每一笔的推荐起笔位置和行笔方向(以归一化的 0-1 坐标表示),用于生成“引导虚线”。
- 维度三:碑帖背景图。为了增加临摹的临场感,我扫描了部分开放的碑帖图片,配上宣纸纹理,作为练习页的背景层。
一个典型的 CharacterModel JSON 结构如下:
json复制{
"char": "永",
"strokes": [
{
"name": "点",
"pathData": "M 0.5 0.1 L 0.52 0.15 C 0.55 0.18 ...",
"normalizedStart": [0.5, 0.1],
"normalizedEnd": [0.58, 0.32],
"suggestedDirection": "右下"
}
],
"fontSize": 0.8,
"recommendedRepeats": 6
}
这种设计的优点是:字帖资源与代码完全解耦,后续想要增加新的字帖、新的字体,只需要新增 JSON 文件和对应的 SVG 资源即可,不需要改动任何业务代码。缺点也很明显——制作一份高质量字帖数据需要花费不少时间,目前我覆盖了 500 个常用字,已经能支撑日常练习。
3. 核心功能模块实现详解
3.1 临摹面板的底层逻辑:从路径渲染到点按捕捉
临摹面板是整个应用的核心,它需要在一片区域内同时显示四样东西:
- 透明的米字格背景;
- 从字帖数据渲染出来的“参考字”(灰色半透明,作为底稿);
- 用户手指或手写笔实时画出的笔画(黑色笔迹);
- 系统给出的引导线(虚线,提示下一笔的位置)。
Flutter 里实现手写捕捉,我首选的是 GestureDetector + CustomPaint 组合。GestureDetector 负责监听 onPanStart、onPanUpdate、onPanEnd,每一次事件回调里拿到触点坐标,转换成局部坐标后追加到当前笔画列表中,最后调用 setState 触发重绘。
这里要特别说明一个细节:不要直接在每个 onPanUpdate 里 setState 然后让整棵树重建。那样性能会非常差,尤其是在中低端设备上,笔画一多直接掉到 30fps 以下。我使用 RepaintBoundary 把画布单独隔离,并且用自定义的 ChangeNotifier 只通知画布部分重绘。实测在鸿蒙设备上,120Hz 的刷新率下连续绘制也能保持比较稳定的帧率。
核心渲染代码简化后大概是这个样子:
dart复制class StrokeCanvasPainter extends CustomPainter {
final List<Stroke> strokes;
final Stroke? currentStroke;
final CharacterModel character;
@override
void paint(Canvas canvas, Size size) {
// 1. 绘制米字格背景
_drawGrid(canvas, size);
// 2. 绘制参考字(半透明)
_drawReferenceChar(canvas, size);
// 3. 绘制用户笔画
for (final stroke in strokes) {
_drawStroke(canvas, stroke);
}
if (currentStroke != null) {
_drawStroke(canvas, currentStroke!);
}
}
@override
bool shouldRepaint(covariant StrokeCanvasPainter oldDelegate) {
return oldDelegate.currentStroke != currentStroke ||
oldDelegate.strokes.length != strokes.length;
}
}
PaintingBinding.instance?.imageCache 不需要额外管理,因为背景图资源不大,加载一次后内存占用可控。真要优化的话,可以把米字格背景也缓存成一张 Picture,这样重绘时省掉重复绘制网格的开销。
3.2 书法笔画评分:不靠玄学,靠数据对比
给用户的字打分,是这个应用最有挑战性的部分。市面上很多练字 App 的评分逻辑就是“跟模板比对一下像素重合度”,这根本不够科学,因为用户写的字不可能跟模板一模一样,像素比对的方式很容易误判。
我的评分算法分为三个维度,以权重加权求和得出总分:
- 结构准确度(权重 40%):将用户笔画轨迹与参考笔画轨迹分别归一化到 64×64 网格,计算两组点集的“相交率”和“平均距离”。相交率越高、平均距离越小,说明笔画位置和参考字越接近。
- 笔锋质量(权重 30%):提取每一笔的起点和终点附近的曲率变化,看是否有明显的提按动作。实现上我是在笔画轨迹上取前 5 个点,计算其切线方向的夹角变化,夹角变化超过阈值的视为“有顿笔”。
- 笔画流畅度(权重 30%):计算触点序列中相邻点之间的距离标准差。标准差越小,说明书写越顺畅,没有多余的抖动或停滞。
评分函数的伪码如下:
dart复制double calculateScore(UserStroke user, ReferenceStroke ref) {
final structure = _structureScore(user.normalizedPoints, ref.normalizedPoints);
final penTip = _penTipScore(user.points);
final fluency = _fluencyScore(user.points);
return structure * 0.4 + penTip * 0.3 + fluency * 0.3;
}
这种多维度的评分方式,至少从逻辑上能区分开“写字结构正确但笔力不足”和“书写流畅但位置完全不对”这两种情况。实际体验中,用户看到的不再是一个笼统的“83 分”,而是“结构 92 分、笔锋 71 分、流畅度 88 分”这种具体反馈,针对性地改进。这也是我刻意做的设计——练字是一个循序渐进的过程,直接告诉你一个总分反而没什么用。
3.3 本地数据库设计:练习记录怎么存、怎么查
练习记录的数据结构其实不复杂,就是用户每天练了哪些字、每个字练了多少遍、平均得分是多少、耗时多久。但如果要做“统计回顾”页面,就需要考虑查询效率。
我设计了四张表:
sql复制CREATE TABLE characters (
id TEXT PRIMARY KEY,
char TEXT NOT NULL,
radical TEXT,
total_strokes INTEGER
);
CREATE TABLE practice_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
date TEXT NOT NULL,
total_time_seconds INTEGER NOT NULL,
char_count INTEGER NOT NULL
);
CREATE TABLE practice_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
session_id INTEGER NOT NULL,
character_id TEXT NOT NULL,
score REAL NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE mistakes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
record_id INTEGER NOT NULL,
mistake_type TEXT NOT NULL,
description TEXT
);
practice_sessions 和 practice_records 用 session_id 关联。每次用户结束一轮练习,就插入一条 session 记录和若干条 detail 记录。这样统计“这个月练了多少个字”只需要按 session 的 date 字段做聚合,统计“哪个字分数最低”则从 records 表里做分组查询。
数据库组件我用的 sqflite,在鸿蒙上跑也没有问题。唯一要注意的是,sqflite 在鸿蒙上需要用到原生平台的数据库库实现,目前社区的适配文档建议在初始化时增加一个 openDatabase 的路径判断,避免默认路径在沙箱里被拒绝。具体写法后面我会在“鸿蒙适配”部分详细说明。
3.4 练习计划与提醒:怎么让用户坚持下来
功能再强大,用户不打开 App 也白搭。所以我加了一个轻量级的练习计划系统。用户可以设定每天的目标字数(默认 10 个字),App 会在每天固定时间推送一条本地通知。
这里没有引入第三方推送 SDK,而是直接用 Flutter 的 local_notifications 插件。鸿蒙上这个插件需要手动配置权限,不配置的话通知不会弹出来。设置路径是 DevEco Studio 里的 module.json5,增加:
json复制{
"requestPermissions": [
{
"name": "ohos.permission.NOTIFICATION_CONTROLLER"
}
]
}
不过要注意,NOTIFICATION_CONTROLLER 属于系统级敏感权限,普通应用申请时容易被拒绝。更稳的做法是引导用户去系统设置里手动打开通知开关。实际测试中,华为手机上首次启动 App 时会弹出通知授权提示,用户在系统弹窗点“允许”就够了,代码层面不需要接管。
4. Flutter 适配鸿蒙的工程配置与打包细节
4.1 环境准备:Flutter 的 Ohos 分支怎么配
这部分是整个项目技术含量最高的地方,也是踩坑重灾区。先说结论:Flutter 适配鸿蒙不等于在 Flutter 代码里写 if (Platform.isHarmonyOS),而是先在工程层面把编译目标切到 OpenHarmony SDK,生成一个鸿蒙原生工程骨架,Flutter 代码作为一个模块被 HAP 包装并运行。
具体来说,我采用的步骤如下:
- 使用 OpenHarmony SIG 提供的
flutter_flutter分支,这是 Flutter 引擎针对 OpenHarmony 的移植版本。需要把它 clone 下来并放到本地 SDK 路径:bash复制git clone https://gitee.com/openharmony-sig/flutter_flutter.git - 切换到 ohos 分支,注意版本号要和后续的 OpenHarmony SDK 版本匹配:
bash复制cd flutter_flutter git checkout ohos-3.7.0 - 安装鸿蒙 DevEco Studio,同时配置好 HarmonyOS SDK(API 版本尽量选择较新的稳定版)。
- 配置
local.properties,告诉 Flutter 工具链 OpenHarmony SDK 的位置:properties复制sdk.dir=/path/to/ohos-sdk - 然后创建一个 Flutter 项目,加上 ohos 目录:
bash复制
flutter create --platforms=ohos my_app
之后用 DevEco Studio 打开生成的 ohos 目录,就能像普通鸿蒙工程一样编译、签名、打包成 HAP。签名配置我建议直接用 DevEco Studio 的自动签名功能,它会为你的应用创建一个调试证书,局域网联调时非常方便。
4.2 手写输入与系统能力的鸿蒙适配方案
手写输入功能本身靠 Flutter 的 GestureDetector 就能完成,不需要原生代码介入。但想要读取鸿蒙系统自带的手写笔数据(比如压力值、倾斜角),就必须通过 PlatformChannel 拿到原生的 MotionEvent 信息。
在鸿蒙上,HarmonyOs 的触摸事件可以通过 onTouchEvent 接口获取,里面包含 tiltX、tiltY 和 pressure。我把这些值通过 MethodChannel 传给 Dart 层,然后在渲染时根据笔压调整笔画的宽度:
dart复制const platform = MethodChannel('calligraphy/pen');
final pressure = await platform.invokeMethod('getPenPressure');
这里有个经验:华为的手写笔(M-Pencil)压力值范围是 0-4096,传递到 Flutter 后最好映射到 0.5-2.0 的倍率范围,否则笔画宽度变化会过于夸张,观感不好。
另外需要注意的是安全区域适配。鸿蒙的折叠屏和带挖孔屏设备上,状态栏、侧边栏的避让区域和安卓不太一样。直接在鸿蒙上测试时,调用 MediaQuery 可能拿不到准确的避让信息,需要在 PlatformBridge 里专门封装一个 safeAreaInsets 的通道,从原生侧读取 window.getWindowAvoidArea 再抛给 Flutter。
4.3 构建产物与设备调试
调试流程跟 Flutter 一致,但命令行有细微区别:
- 连接鸿蒙设备后,用 DevEco Studio 的 hdc(HarmonyOS Device Connector)工具确认设备在线:
bash复制
hdc list targets - 然后在项目根目录执行:
bash复制
这个命令会自动把 Flutter 引擎和 Dart 代码打包到鸿蒙工程里,并在设备上启动。flutter run -d <device-id>
如果只想打包 HAP 给其他人安装,执行:
bash复制flutter build hap --release
生成的文件在 build/ohos/release/ 目录下。我第一次打 Release 包时踩了个坑:HAP 体积比 APK 大不少,因为默认带了一整套 Flutter 引擎的 so 库。后续可以通过配置 abiFilters 只保留 arm64-v8a 架构来减小体积:
json复制{
"abiFilters": ["arm64-v8a"]
}
中文环境下实测,只保留 arm64 架构后 HAP 体积从 78MB 降到了 42MB,效果明显。
5. 实测数据与功能效果复盘
5.1 真机运行表现:帧率、内存、安装包体积
我在三台设备上做了实测:一台华为 Mate 60 Pro(鸿蒙 4.0)、一台华为 MatePad Pro(鸿蒙 4.2)、还有一台旧款小米 10(安卓 12)作为对照。测试场景是连续 30 分钟临摹练习,记录核心指标。
| 设备 | 平均帧率 | 帧率波动 | 内存占用峰值 | 启动耗电增量 |
|---|---|---|---|---|
| Mate 60 Pro | 117 FPS | ±3 FPS | 312MB | 3.2% |
| MatePad Pro | 113 FPS | ±5 FPS | 348MB | 2.8% |
| 小米 10 | 102 FPS | ±8 FPS | 365MB | 4.1% |
从数据看,鸿蒙设备对 Flutter 的渲染支持和硬件调度做得比预期好,Mate 60 Pro 上几乎全程拉满 120 帧。让我惊喜的是手写笔的延迟,配合 M-Pencil 写字的延迟体感比安卓端低,应该是因为鸿蒙的触摸事件分发管线对笔输入做了专门优化。
5.2 用户练习效果:评分维度是否可靠
我在内部邀请了 6 位同事试用,每人连续一周每天练 10 个字。统计结果显示,使用 App 评分反馈后,大家的平均分从第一天的 71 分提升到第七天的 79 分,其中“结构准确度”维度的提升最明显,平均提高了 11 分。
不过要注意,这个分数只是帮助用户自我观察的参考指标,别过度神话。我自己测试时发现,写行书时如果连笔稍微多一点,“笔锋质量”维度就容易被扣分,这其实是算法对字体风格的偏见。后面我在评分里加了一个“书写风格”开关,用户可以选择“楷书模式”和“行书模式”,不同模式对应的曲率阈值不一样,评分才相对公允。
6. 常见构建与运行问题排查速查表
开发过程中我整理了十个出现频率最高的问题,贡献给需要的人:
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
flutter run 找不到鸿蒙设备 |
hdc 服务未启动或设备未授权 | 执行 hdc list targets;确认手机开发者模式已开启,首次连接时在手机上点击“允许调试” |
编译时报错 Please check your hvigor version |
DevEco Studio 的 hvigor 版本与项目不匹配 | 在 ohos 目录下执行 hvigorw --version,对照 DevEco Studio 要求安装对应版本 |
HAP 安装失败,提示 install sign info error |
证书配置不正确 | 使用 DevEco Studio 的自动签名功能重新生成证书,并确认 build-profile.json5 里的 signingConfigs 已指向新证书 |
| 字体文件加载失败 | 字体文件路径存在中文字符 | 确保 asset 路径全英文化,字符编码使用 UTF-8 无 BOM |
| 手写笔画延迟明显 | 直接在主 Isolate 做了图片重采样 | 将图片缩放入 compute() 函数中执行,或使用 Isolate.run() 处理 |
| 通知没有弹出 | 未授权通知权限 | 到系统设置——应用——对应应用——通知里打开开关,或者用引导弹窗申请授权 |
sqflite 打开数据库时崩溃 |
沙箱路径问题 | 将数据库路径改为 getDatabasesPath() 返回的路径,不要硬编码 /data/data |
| 启动时白屏 3 秒以上 | Flutter 引擎在鸿蒙上首次加载较慢 | 使用 DevEco Studio 的启动优化配置,添加 "startupMode": "cold" 并开启启动闪屏页 |
| 页面从后台恢复后笔画消失 | 画布状态未持久化 | 在 onPause 时将笔画序列化到本地,onResume 时重新装载 |
| 自定义字体在鸿蒙上被替换成默认字体 | 鸿蒙系统对 License 受限字体的处理策略 | 改用系统允许的字体格式,或者把字体内嵌到 HAP 的 rawfile 目录下并手动注册 |
除了表格里的这些,还有一个值得单独说的坑——Flutter 各版本不一致导致依赖包下载失败。我这台机器上之前装的是 Flutter 3.10 稳定版,切换到 ohos 分支后发现很多插件版本对不上。解决办法是统一锁定 pubspec.yaml 里的插件版本,把所有依赖都换成兼容 ohos 分支 3.7.0 的版本组合。具体的版本列表我放在项目的 pubspec.lock 里,大家参考时直接照抄即可。
7. 后续扩展方向与我的经验复盘
项目做到这里,核心功能已经稳定,但我知道还有很多可以继续优化的方向。
第一个是 AI 辅助点评。目前的评分机制是基于几何特征的,但真正的书法老师会看“气韵”“章法”这种偏感受的东西。我调研过,理论上可以用一个轻量级的图像分类模型,把用户的字截图喂给模型,输出“瘦劲”“丰腴”“险峻”等风格标签。只不过这个模型需要大量标注数据,短期内很难做到准确,可以作为 v2.0 的规划。
第二个是 多端云同步。虽然我坚持本地优先,但如果用户换手机,练习数据就丢了。我计划后续加一个可选的“加密同步”功能,通过用户手动导出的方式备份到本地文件或网盘,不上传任何服务器,保持隐私底线。
第三个是 手写笔专用模式。现在只是简单读取了压感,但华为 M-Pencil 的倾斜角、橡皮擦手势都还没用上。如果能把这些能力全部打通,体验会好一个档次。
最后再分享一个我个人的体会:做跨平台开发,千万别迷信“一套代码走天下”。哪怕 Flutter 已经帮我们抹平了 90% 的差异,剩下那 10% 的系统级适配才是真正决定用户好感度的部分。比如这次鸿蒙的通知权限、安全区域、手写笔压感,每一处都需要单独写平台层代码。跨平台的根本价值不是省掉所有原生开发,而是让你把原生开发精力集中在真正重要的系统能力上。把一套代码跑通三端这件事,本身就是非常值得投入的。
