这段时间把一个偏生活工具的小应用从 Android/Flutter 的思路搬到 HarmonyOS 上,做了任务、备忘录、记账、地图足迹、长图导出、提醒、OCR 识图收纳、底部沉浸导航这些功能。

从 Android / Flutter 到 HarmonyOS ArkTS 的迁移流程图
把迁移过程拆成六个检查点:环境、权限、状态、系统能力、真机适配和上架复核。

整体感受是:鸿蒙不是 Android 换皮,也不是 Flutter 的另一套 UI 皮肤。它的 ArkTS、权限模型、图库写入、通知提醒、深色模式、组件截图、上架审核都更“规矩”,很多在 Android 上顺手的事情,到了 HarmonyOS 上需要换一种写法。

这篇不是教程大全,只记录我实际踩过的坑,以及最后比较稳的处理方式。

本文基于 HarmonyOS / DevEco Studio / ArkTS 的实战经验整理。SDK、API 和审核规则会变化,真正上架前一定要以当前 DevEco、AppGallery Connect 和华为开发者文档为准。

一、SDK 路径和构建环境别靠猜

最开始我遇到过 DevEco 预览器正常,但命令行编译报:

1
SDK component missing

后来发现本机 SDK 并不在默认位置,而是在类似:

1
$env:DEVECO_SDK_HOME='D:\harmony\harmonyos\DevEco Studio\sdk'

命令行构建要显式指定 SDK:

1
2
3
4
$OutputEncoding = [System.Text.UTF8Encoding]::new()
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new()
$env:DEVECO_SDK_HOME='D:\harmony\harmonyos\DevEco Studio\sdk'
& 'D:\harmony\harmonyos\DevEco Studio\tools\hvigor\bin\hvigorw.bat' assembleApp

避坑建议:

  • DevEco 预览器能跑,不代表命令行构建环境一定正确。
  • 换电脑、换 SDK 路径、换 DevEco 版本后,先跑一次 hvigorw.bat assembleApp
  • Windows 终端里最好设置 UTF-8 输出,不然中文日志和报错容易看着像乱码。
  • 如果审核要求 API 6.1.0(23),要确认工程配置、签名证书、SDK 能力和实际构建目标一致。

二、鸿蒙的权限比 Android 更像“场景申请”

Android 开发里很多权限像是“我声明了,运行时弹一下,用户同意就能用”。鸿蒙这边更强调:你为什么需要、在哪个场景用、用多久、用户是否明确触发

我踩过的典型问题:

1
2
3
failed to install bundle. code:9568289
install failed due to grant request permissions failed
PermissionName: ohos.permission.WRITE_IMAGEVIDEO

还有:

1
ohos.permission.SHORT_TERM_WRITE_IMAGEVIDEO

以及审核时提示:

1
软件包 user_grant 类型权限与隐私政策中声明的权限不一致

这类问题不能只靠“把权限加进 module.json5”解决。尤其是保存图片到图库,最好优先使用系统给的受控入口,比如 SaveButton,让用户明确点击“保存图片”后获得短时授权。

我的处理方式:

  • 保存图片按钮使用系统 SaveButton
  • 点击成功后立刻执行图库保存,不再中间弹自定义头像/用户名窗口。
  • 用户名和头像改放到“我的”页提前设置,避免短时授权过期。
  • 不需要的图片/视频权限不要乱写进配置。
  • 隐私政策里声明的权限,要和包里实际申请的权限一致。

和 Android 的差异:

Android 常见做法是申请 READ_MEDIA_IMAGESWRITE_EXTERNAL_STORAGE 或走系统选择器。HarmonyOS 更推荐“用户主动触发 + 系统授权控件 + 受控写入”。如果你在保存前弹了一个自己的设置窗口,短时授权可能已经不是原来的那次用户动作了。

三、深色模式不是“能看见”就算适配

喵球第一次被审核打回,有一条就是深色模式输入框文字看不清:

  • 任务 - 新建任务:输入框文字看不清
  • 推录 - 备忘录/记账 - 新建:输入框文字看不清
  • 地图 - 编辑足迹/省份列表 - 点击标记:输入框文字看不清

这类问题的本质是:系统进入深色模式后,TextInputTextArea、弹窗背景、placeholder、caret、文字颜色如果没有明确设置,就可能被系统默认颜色影响。

我的修法:

1
2
3
4
5
TextInput({ placeholder: '标题', text: this.taskDraftTitle })
.fontColor('#243F5C')
.placeholderColor('#6F839A')
.caretColor('#4D739C')
.backgroundColor('#F4FAFF')

类似地,弹窗和卡片也要明确设置背景色、文字色、边框色。不要以为浅色模式好看,深色模式就会自动好看。

避坑建议:

  • 所有输入控件都显式设置 fontColorplaceholderColorcaretColorbackgroundColor
  • 弹窗、底部 Sheet、列表卡片不要依赖默认背景。
  • 上架前在真机切深色模式跑一遍核心流程。
  • 浅色模式也要检查对比度,审核会抓控件坐标和对比度。

四、状态刷新要主动设计,不能指望“数组改了就全刷新”

从 Flutter 过来,很容易带着 setState 的思维:我改了数组,UI 应该马上刷新。

ArkUI 当然也会响应状态变化,但我实际遇到过这些问题:

  • 地图标记“想去/去过”后,上方统计数字不刷新。
  • 记账保存后,下面列表更新了,但上面的收入支出卡片没更新。
  • 备忘录总数卡片不刷新。
  • 首页今日任务图表要重开 App 才刷新。
  • 任务状态从待开始变进行中,需要切页才刷新。

最后我的做法是给重要业务区加“刷新 tick”:

1
2
3
@State taskStatsTick: number = 0;
@State recordStatsTick: number = 0;
@State homeRefreshTick: number = 0;

每次保存任务、完成任务、删除任务、保存备忘录、保存地图标记后,主动:

1
2
3
4
this.taskStatsTick += 1;
this.recordStatsTick += 1;
this.refreshHomeWidgets();
this.persistAppData();

有些统计函数里也会读一下 tick,让它成为 UI 依赖:

1
2
const reactiveTick = this.recordStatsTick;
return count + reactiveTick - reactiveTick;

这看起来有点“土”,但对复杂页面很管用。

和 Flutter 的差异:

Flutter 里你可能更习惯 setState()、Provider、Riverpod、Bloc。HarmonyOS ArkTS 里如果页面由很多 @Builder、数组派生统计、多个 Tab、多个弹窗组成,最好一开始就把“哪些动作会刷新哪些区域”设计清楚。

五、持久化别忘了,预览器里的数据不是魔法

我一开始也犯过很普通的错:页面看着能添加任务和备忘录,但重新打开 App 数据没了。

原因很简单:只存在 @State 里,没有落盘。

后来改成统一的快照结构:

1
2
3
4
5
6
7
8
interface AppStateSnapshot {
activeTab: string;
tasks: TaskItem[];
records: RecordItem[];
provinces: ProvinceVisit[];
birthDate: string;
soundEnabled: boolean;
}

关键动作后统一 persistAppData(),启动时读取并 applyAppStateSnapshot()

避坑建议:

  • 不要等功能快做完才补持久化。
  • 数据结构要考虑后续升级,字段读取时给默认值。
  • 导入/导出 JSON 很适合做工具 App 的兜底能力。
  • 每次修改数据模型后,用旧数据启动一次,确认不会崩。

六、图库保存和系统分享要分开处理

备忘录长图导出是我踩坑最多的功能之一。

遇到过的问题:

  • 导出图片失败。
  • 保存后提示成功,但图库里找不到。
  • 系统分享后一直显示“分享中”。
  • 生成的图片底部大片空白。
  • 九张图时导出图片正文消失。
  • 预览页图片删了但 UI 还留着旧图。

最后拆成几件事处理:

1. 保存图库走 SaveButton

用户点击系统保存按钮后,马上执行保存,不要中间再弹自定义设置弹窗。

2. 分享状态一定要 finally 复位

1
2
3
4
5
6
7
try {
// share
} catch (error) {
this.showToast('系统分享暂时不可用');
} finally {
this.isSharingRecordImage = false;
}

否则分享失败或取消后,页面会一直卡在“分享中”。

3. 导出图片的 UI 不一定适合直接复用页面 UI

我一开始把阅读页九宫格直接拿去截图。页面上看着没问题,但离屏截图时 Grid 高度不稳定,九张图会把正文、关联地图、水印挤出画面。

最后改成:

  • 阅读页可以用漂亮的九宫格。
  • 导出图使用固定尺寸的 Flex 九宫格。
  • 导出高度单独估算。
  • 图片区域、正文、标签、关联信息、水印都预留高度。

类似这样:

1
2
3
4
5
6
7
8
private recordShareSnapshotHeight(record: RecordItem, includeImage: boolean): number {
const imageCount = includeImage ? this.recordImageUris(record).length : 0;
const imageRows = imageCount > 0 ? Math.ceil(imageCount / 3) : 0;
const textHeight = this.recordShareTextLineCount(record.text) * 26;
const imageHeight = imageRows > 0 ? imageRows * 110 + 22 : 0;
const linkedHeight = record.linkedTaskTitle.length > 0 || record.linkedProvinceName.length > 0 ? 56 : 0;
return Math.min(12000, Math.max(210, 152 + imageHeight + textHeight + linkedHeight + 42));
}

核心教训:页面组件和导出组件最好分开。
导出图片是“排版生成”,不是“把当前页面截一下就完事”。

七、九宫格图片别只看预览器

备忘录图片最多九张,我遇到过:

  • 编辑页删除图片,图片没有消失。
  • 继续添加图片后,仍显示旧图。
  • 预览页从九宫格变成两列。
  • 其中一张图片突然空白。

最后的处理方式:

  • 编辑页图片数组统一走 setRecordDraftImages()
  • 删除图片按下标删,不按 URI 删。
  • 图片组件 key 里加刷新 tick。
  • 普通卡片九宫格和导出九宫格使用不同尺寸。
  • 卡片内宽不足时,不要固定太大的单元格。

比如普通预览用更稳的尺寸:

1
this.MemoImageGrid(this.recordImageUris(record), record.id.toString(), 86)

导出图再用更大的:

1
this.MemoImageGrid(this.recordImageUris(record), record.id.toString(), 102, false)

和 Flutter 的差异:

Flutter 里 GridView.count(crossAxisCount: 3) 对尺寸约束比较直观。ArkUI 里如果你在不同容器、不同 Sheet、离屏截图里复用同一个 Grid,最好确认组件的实际宽高,否则就会出现“页面上还行,导出炸了”的情况。

八、提醒功能不是普通通知那么简单

任务提醒是另一个大坑。

普通通知权限给了,不代表定时提醒一定能成功。尤其是后台、息屏、系统限制、代理提醒能力、审核场景说明,都要考虑。

我后来申请提醒能力时,需要准备类似这些信息:

  • 使用场景:任务到时间提醒用户。
  • 申请理由:工具 App 不联网,需要本地按计划提醒。
  • 通知标题:任务标题。
  • 通知文本:任务备注或开始时间。
  • 通知场景:用户主动创建任务并设置提醒时间。
  • 通知频率:由用户创建任务数量决定,不做营销推送。

避坑建议:

  • 不要把“通知权限已授权”等同于“定时任务一定会弹”。
  • 提醒类能力要看当前 HarmonyOS 推荐的代理提醒方案。
  • 删除任务时同步删除提醒。
  • 保存任务时同步注册提醒。
  • 给用户显示明确状态:已开启、注册失败、系统限制等。

和 Android 的差异:

Android 里常见是 AlarmManagerWorkManager、前台服务、通知渠道。HarmonyOS 更强调系统代理提醒和使用场景合规,审核也会关心是不是必要、是不是用户主动触发、频率是否合理。

九、底部导航和系统滚动条也会影响观感

我一开始自己做底部导航,后来换成更接近官方的 Tabs / BottomTabBarStyle 思路,再叠加沉浸光感玻璃效果。

但又遇到一个细节:五个页面右侧都会出现系统滚动条,工具 App 的卡片式页面看起来很突兀。

处理方式是在主页面 Scroll 上关闭滚动条:

1
2
3
4
5
6
7
Scroll() {
Column() {
this.HomePage()
}
}
.width('100%')
.scrollBar(BarState.Off)

避坑建议:

  • 底部栏尽量用系统推荐组件或相近结构。
  • 沉浸/玻璃效果要真机看,预览器不一定准。
  • ScrollBar、状态栏、导航栏区域都要单独检查。
  • 不要只看单页,要切五个 Tab 看整体一致性。

十、上架审核和 Android 最大的不同

Android 应用上架当然也有审核,但 HarmonyOS 审核给我的感觉是:UX 规范、权限声明、深色模式、对比度、功能即时刷新这些细节会被抓得很具体

我遇到过的审核反馈包括:

  • 深色模式输入框文字看不清。
  • 浅色模式文字和背景对比度不足。
  • 地图“想去”却计入“去过次数”,逻辑不符合预期。
  • 修改标记后需要切换页面才刷新。
  • 保存图片失败。
  • 图片预览只显示第一次添加的图片。
  • 系统分享后一直显示分享中。
  • user_grant 权限与隐私政策不一致。
  • 使用 beta API 构建。

这些问题在开发时看起来都不算大,但审核时就是会被打回。

我的上架前检查清单:

  • 浅色模式完整跑一遍。
  • 深色模式完整跑一遍。
  • 真机跑,不只看预览器。
  • 所有新增、编辑、删除后统计卡片立即刷新。
  • 所有弹窗输入框文字清晰。
  • 权限声明和隐私政策一致。
  • 保存图片、分享、导入导出 JSON 都实际操作一遍。
  • 关闭后台、重启 App 后数据还在。
  • 删除任务后提醒也删除。
  • 地图“想去”和“去过”统计不能混。
  • 预览页和导出图都要测长文字、多图片、关联地图。

十一、从 Android/Flutter 迁移到鸿蒙的思维变化

如果一句话总结:

Android/Flutter 更像“我来掌控应用”,HarmonyOS 更像“在系统规范里完成体验”。

几个明显差异:

方面 Android / Flutter 常见思路 HarmonyOS / ArkTS 更稳的思路
UI 状态 setState 或状态管理刷新 @State + 明确刷新 tick + 派生数据依赖
图片保存 权限或 MediaStore 系统 SaveButton / PhotoAccessHelper / 用户触发
通知提醒 AlarmManager / WorkManager 通知权限 + 代理提醒能力 + 场景申请
页面截图 截 Widget / RepaintBoundary 组件快照要注意图片加载和离屏布局高度
深色模式 主题适配为主 输入框、弹窗、卡片最好显式颜色
上架审核 权限和内容为重点 UX、对比度、功能即时性、权限一致性都很细
本地文件 路径和 SAF Picker、短时授权、沙箱和用户选择更重要
底部导航 自定义很常见 尽量贴近系统组件和 UX 规范

十二、我现在会怎么从零开始做

如果重新做一次,我会这样安排:

  1. 先确定目标 API、SDK、签名证书和命令行构建。
  2. 先搭浅色/深色颜色体系,不等审核打回再补。
  3. 数据模型和持久化第一天就做。
  4. 每个业务模块设计刷新 tick。
  5. 所有保存、删除、编辑都马上刷新统计卡片。
  6. 图片选择、九宫格、导出图从一开始就分页面组件和导出组件。
  7. 保存图库只走用户明确触发的系统入口。
  8. 提醒功能先查当前官方方案,再写代码。
  9. 真机测试优先级高于预览器。
  10. 上架前按审核视角做一次“找茬测试”。

最后

鸿蒙开发并不难到不能做,但它确实不是把 Android/Flutter 代码翻译一遍就结束。

它最容易让人崩溃的地方,不是某个 API 不会用,而是很多小细节叠在一起:权限、深色模式、状态刷新、截图、图库、提醒、审核规范。每个单看都不大,凑在一起就很磨人。

但反过来,如果一开始就按鸿蒙的规则设计:用户明确触发、系统受控授权、UI 状态即时刷新、深色模式显式适配、导出组件独立排版,那么这个平台其实可以做出挺细腻的工具应用。

喵球这次一路修下来,我最大的感受是:别和系统拧着来,顺着它的规范做,反而更稳。