ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

鸿蒙App纯命令行开发实践:从环境搭建到签名上架全流程

鸿蒙App纯命令行开发实践:从环境搭建到签名上架全流程 1. 先说清楚为什么这个项目我放弃了纯鼠标操作DevEco CLI 这套命令行工具我最早是在给团队搭鸿蒙 App 构建流水线的时候才真正用起来的。之前做「宝贝日程表」这个鸿蒙 App从建工程到打 release 包我全程没怎么打开图形界面——不是刻意炫技而是这个项目本身就是给我自己和身边几个新手爸妈用的功能不复杂迭代却特别碎一天改三回文案、两天调一次提醒逻辑图形 IDE 的启动、索引、同步那一套开销反而拖慢了我。命令行建工程、命令行装依赖、命令行出包改完一个文件直接一行命令推到设备上看效果这个节奏一旦适应了就回不去。「宝贝日程表」要做的事情很朴素记录宝宝一天里的喂养、睡眠、换尿布、吃药、打疫苗、体检这些节点到点了弹个提醒顺手在桌面卡片上显示“下一件事是什么、还有多久”。听起来像个小工具但真正做过育儿类 App 的人都知道它的难点从来不在界面画得多花而在于三件事数据不能丢、提醒必须准、单手操作要快。半夜三点你抱着娃另一只手在屏幕上戳多一个跳转、多一次输入体验就是天壤之别。这篇内容我打算把整个链路摊开讲从命令行的环境对齐到工程骨架的搭建到 RDB 数据层、提醒服务、桌面卡片的落地再到用命令行出签名包、走完上架材料准备。适合已经会一点 ArkTS、想摆脱纯图形操作提效率的人也适合完全没碰过鸿蒙、但想照着一步步复现一个新项目出来的朋友。涉及的 API 我会标注版本基线写清楚每一步为什么这么做以及我在哪些地方确实摔过跟头。2. 开工之前环境与工具链的对齐2.1 命令行工具到底包含哪些东西很多人一听“DevEco CLI”以为是某个单独安装的可执行文件。实际情况是鸿蒙这套命令行能力是分散在几个工具里的平时被大家统称为命令行工具链主要是下面这几类hvigor构建系统负责编译、打包、多模块编排命令行入口就是工程根目录下那个hvigorwWindows 上是hvigorw.bat。ohpm包管理器等价于前端圈的 npm、Java 圈的 maven三方库的安装、更新、查看依赖树都靠它。hdc设备连接与调试桥装包、卸载、拉日志、执行 shell 命令。codelinter静态代码检查能在提交前把一批常见问题挡下来。sdkmgr / 签名工具SDK 组件管理以及打正式包时用到的签名工具。理解了这个分工后面所有命令你就不会觉得零散。我习惯把这套工具理解成一条流水线ohpm 管“原料”hvigor 管“加工”hdc 管“出厂试跑”codelinter 管“质检”签名工具管“盖章”。提示工具版本和 SDK 版本必须对齐。我吃过一次亏DevEco Studio 升级到新版之后命令行里 hvigor 还是老的结果构建报了一堆看不懂的插件解析错误查了两小时才发现是版本错位。2.2 版本基线与环境变量我这次项目的基线是 DevEco Studio 5.0.x 配套的 API 12工程模板用 Stage 模型。选 API 12 不是因为保守而是因为「宝贝日程表」这类工具类应用对系统新特性没有强依赖API 12 的覆盖面和稳定性对个人开发者最友好。如果你要做更激进的能力可以往上走但上架审核对新版本特性有时候会更谨慎这个后面会再说。环境变量这块最少要配三个# 把 HarmonyOS SDK 里的工具目录加进 PATH export DEVECO_SDK_HOME/Users/you/Library/Huawei/Sdk export PATH$DEVECO_SDK_HOME/default/openharmony/toolchains:$PATH export PATH$DEVECO_SDK_HOME/default/openharmony/toolchains/lib:$PATHWindows 下就是在系统环境变量里加对应的目录注意路径不要带中文和空格我见过同事把 SDK 装在“D:\我的软件\鸿蒙开发\”下面编译时各种诡异的路径解析失败改回纯英文路径就正常了。这类“玄学问题”九成是路径字符集和长度惹的祸。验证是否配好跑这几条ohpm -v hdc -v node -vhvigorw不用单独验证它随工程走每个鸿蒙工程根目录都会有。ohpm 依赖 Node.js 环境Node 版本我建议锁在 18 LTS太新的版本偶尔会有兼容性抖动。2.3 工程的创建方式模板还是手写命令行不提供“一键创建鸿蒙工程”的官方交互式脚手架。我摸索出来的最实用做法是两条路第一条用 DevEco Studio 创建一个空模板工程Empty Ability建完之后立刻关掉图形界面之后全部用命令行操作。这样既拿到了正确的目录结构和配置文件又省下了后面每次启动 IDE 的开销。我这次走的就是这条路。第二条直接从已有的、结构干净的小工程复制一份改包名和模块名。适合你已经有稳定模板沉淀的情况我现在的做法是把一个“标准骨架”存在 Git 仓库里新项目直接 clone 改名字。要提醒一点包名bundleName一旦定了上架后就不能改所以别用com.example.xxx这种占位符直接上架。我这次用的是自己域名反写的形式规划阶段就想清楚了。3. 工程骨架每一层文件夹都不是随便放的3.1 目录结构逐层拆解「宝贝日程表」的工程结构大致是下面这样我把每个目录的职责都标出来你对着看会清楚很多BabySchedule/ ├── AppScope/ # 应用级配置与资源 │ ├── app.json5 # 包名、版本、图标、标签 │ └── resources/ # 应用级图标、字符串 ├── entry/ # 主模块HAP │ ├── src/main/ │ │ ├── ets/ │ │ │ ├── entryability/ # 入口 UIAbility │ │ │ ├── entrybackupability/ │ │ │ ├── pages/ # 页面 │ │ │ ├── model/ # 数据模型 │ │ │ ├── db/ # RDB 封装 │ │ │ ├── service/ # 提醒、卡片等能力 │ │ │ └── common/ # 常量、工具函数 │ │ ├── module.json5 # 模块配置、权限声明 │ │ └── resources/ # 模块级资源 │ ├── build-profile.json5 # 构建参数 │ ├── hvigorfile.ts │ └── oh-package.json5 ├── build-profile.json5 # 工程级构建配置 ├── hvigorfile.ts ├── oh-package.json5 └── local.properties # 本地 SDK 路径不要提交到 Git为什么我要把 model、db、service 单独拆出来因为「宝贝日程表」虽然小但它至少有三种日程类型未来很可能加数据导出、宝宝多档案、家庭共享。如果一开始就把读写数据库的代码散在页面里后面每加一个功能都要去翻页面文件很容易改漏。分层不是为了架构好看是为了让我三个月后回来还能看懂自己写了什么。3.2 关键配置项别写错AppScope/app.json5是应用的门面几个字段要重点看{ app: { bundleName: com.yourdomain.babyschedule, vendor: yourname, versionCode: 1000000, versionName: 1.0.0, icon: $media:app_icon, label: $string:app_name } }versionCode是整数上架后每次提交新版本必须比上一版大我习惯用 1000000 起步每提审一次加 1。versionName是给人看的比如 1.0.0、1.0.1可以自由一点但不要和 versionCode 混淆我见过有人把 versionName 写成时间戳审核那边反馈说版本号不规范。entry/src/main/module.json5里要声明权限和入口 Ability{ module: { name: entry, type: entry, abilities: [ { name: EntryAbility, srcEntry: ./ets/entryability/EntryAbility.ets, exported: true, skills: [ { entities: [entity.system.home], actions: [action.system.home] } ] } ], requestPermissions: [ { name: ohos.permission.PUBLISH_AGENT_REMINDER, reason: $string:reason_reminder, usedScene: { abilities: [EntryAbility], when: always } } ] } }权限这块我要多说一句申请权限时reason和usedScene必须写清楚用途上架审核会看。我第一版只写了个“需要提醒权限”被打回来要求补充具体使用场景后来改成“用于在您设定的喂养、服药时间到达时推送提醒”就通过了。3.3 用 ohpm 管依赖别手动拷贝ohpm 的用法和 npm 很像ohpm install 包名装依赖ohpm list看依赖树。三方库我这次只用了一个做日期格式化的轻量库其余全靠系统 API。理由很简单育儿类应用的稳定性比功能丰富度重要得多每引入一个三方库就多一份未来系统升级时不兼容的风险。# 安装依赖 ohpm install ohos/xxx # 查看当前依赖 ohpm list # 清理并重装遇到依赖混乱时很好用 rm -rf oh_modules ohpm install注意oh_modules目录和oh-package-lock.json5的关系相当于 node_modules 和 package-lock.json。前者不要提交到 Git后者要提交否则团队协作时依赖版本会飘。4. 核心功能数据、提醒、卡片三条线4.1 RDB 数据层把“不能丢”这件事做扎实育儿记录最怕的就是丢数据。我选关系型数据库 RDB 而不是首选项 preferences原因在于日程数据是结构化的、会持续增长、还需要按时间段做统计查询比如“这周平均夜醒几次”preferences 那种键值对存储撑不起这个场景。建表语句我这样设计import { relationalStore } from kit.ArkData; const CREATE_TABLE CREATE TABLE IF NOT EXISTS schedule ( id INTEGER PRIMARY KEY AUTOINCREMENT, baby_id INTEGER NOT NULL, type INTEGER NOT NULL, title TEXT NOT NULL, start_time INTEGER NOT NULL, repeat_rule INTEGER DEFAULT 0, remark TEXT, done INTEGER DEFAULT 0, created_at INTEGER );字段解释一下type我用整数枚举1 是喂养、2 是睡眠、3 是换尿布、4 是吃药、5 是疫苗、6 是体检存整数比存字符串省空间也更方便做分组统计start_time存毫秒时间戳跨时区、做排序、算间隔都方便repeat_rule用位标记表示是否每天重复、是否工作日重复简单够用。baby_id是为了支持多个宝宝很多家庭有双胞胎这个字段必须一开始就留。数据库初始化我封装成了一个单例export class DbHelper { private static store: relationalStore.RdbStore | null null; static async init(context: Context): Promisevoid { if (DbHelper.store) return; const config: relationalStore.StoreConfig { name: baby_schedule.db, securityLevel: relationalStore.SecurityLevel.S1 }; DbHelper.store await relationalStore.getRdbStore(context, config); await DbHelper.store.executeSql(CREATE_TABLE); } static getStore(): relationalStore.RdbStore { if (!DbHelper.store) throw new Error(db not init); return DbHelper.store; } }securityLevel我选了 S1。很多人纠结要不要选更高等级我的经验是这个等级直接关系到数据库文件在设备上的加密保护强度但选太高会影响跨设备同步的兼容性。育儿日程不带什么敏感信息S1 足够选择依据要和数据敏感度匹配不是越高越好。4.2 页面交互单手能完成的表单才合格「宝贝日程表」的录入页是我改得最多的一页。第一版做了完整的表单标题、类型、开始时间、结束时间、备注、重复规则结果自己用的时候发现半夜记录一次喂养要点七八下屏幕太累。第二版我砍成了“三键完成”底部三个大按钮喂养、睡眠、换尿布点一下直接以当前时间记一条类型和标题自动填好长按按钮进入详情编辑补时间、加备注吃药、疫苗、体检这类低频事件走右上角“”进入完整表单。ArkTS 里做这个布局核心是 Grid 加自定义组件Component struct QuickActionButton { Prop label: string; Prop typeValue: number; onQuickAdd: (type: number) void () {}; build() { Button(this.label) .width(100%) .height(72) .fontSize(20) .onClick(() this.onQuickAdd(this.typeValue)) .gesture( LongPressGesture() .onAction(() { // 长按进入详情编辑 }) ) } }这里有个细节值得说Prop用来接收父组件传入的值单向同步父变子变如果子组件要回传数据不能直接改Prop得用回调函数我上面就是这么处理的。新手常犯的错误是试图在子组件里直接改Prop修饰的变量编译不报错但行为不符合预期。还有一个体验上的坑时间选择器默认是 24 小时制但很多家长习惯 12 小时制。我在设置页加了个开关用StorageLink做全局状态同步改一处全应用生效。这类小细节恰恰是这类工具类应用能不能被长期使用的关键。4.3 提醒服务准不准决定成败提醒是「宝贝日程表」的灵魂。我最初的方案是应用内起一个定时器到点弹通知。实测下来问题很明显应用切到后台、被系统回收之后定时器就废了提醒直接失效。这个方案只在应用前台可用对育儿场景毫无意义。正确做法是用系统的代理提醒能力。核心代码大致是这样import { reminderAgentManager } from kit.BackgroundTasksKit; async function publishReminder(title: string, date: Date): Promisenumber { const req: reminderAgentManager.ReminderRequestAlarm { reminderType: reminderAgentManager.ReminderType.REMINDER_TYPE_ALARM, hour: date.getHours(), minute: date.getMinutes(), daysOfWeek: [], title: title, content: 到时间啦别忘了记录, notificationId: Math.floor(Math.random() * 100000), slotType: notificationManager.SlotType.SOCIAL_COMMUNICATION }; return await reminderAgentManager.publishReminder(req); }几个实操要点必须提醒第一ReminderRequestAlarm是按“时分”触发的不带年份日期。如果你的场景是“三个月后打疫苗”这种远期提醒单纯用这个类型不够得自己算好月底层逻辑或者干脆用日历类型ReminderRequestCalendar来指定具体的年月日时分。我最后是混合用的当天内的短提醒用 Alarm 类型远期事件用 Calendar 类型。第二notificationId要自己保证在一批提醒里唯一重复了后面的会覆盖前面的。我一开始用一个固定值结果多个提醒只剩最后一个排查了半天。第三代理提醒有数量上限我记得是不超过 30 条左右不同版本有差异。所以不要给每个日程都排提醒而是采用“滚动窗口”策略只给未来 7 天内的日程排提醒应用每次启动时刷新这个窗口。这样既不会超限也能覆盖实际使用场景。第四如果你发现代理提醒权限申请不下来或者能力受限退路是把提醒降级为“应用在前台时的本地通知 桌面卡片倒计时”至少不会出现“什么都没有”的尴尬。我建议在代码里就把这个降级分支写好try/catch兜住别等用户投诉了才想起来。4.4 桌面卡片把“下一件事”放在眼皮底下桌面卡片Form是这类日程 App 的加分项也是我这次最费劲的地方。目标是卡片上滚动显示“距离下一次喂养还有 1 小时 20 分”。卡片由独立的 FormExtensionAbility 提供数据由应用侧主动推送import { formBindingData, formProvider } from kit.FormKit; async function updateCard(formId: string, nextText: string): Promisevoid { const obj { nextTitle: nextText, updateTime: new Date().toLocaleTimeString() }; const data formBindingData.createFormBindingData(obj); await formProvider.updateForm(formId, data); }绕不开的一个约束是卡片的刷新不能太频繁系统对刷新频率有节流。所以“每分钟刷新倒计时”这种想法不现实。我的做法是内容以“小时:分钟”为粒度显示卡片每隔一段时间刷新一次同时用户点击卡片进入应用时立刻刷一次。这个折中方案实测体验可以接受也不会因为频繁刷新被系统限制。5. 命令行出包从构建到签名的完整链路5.1 构建命令与产物路径日常开发用 debug 构建就够了# 构建 debug HAP ./hvigorw assembleHap --mode module -p productdefault -p buildModedebug --no-daemon # 构建 release HAP ./hvigorw assembleHap --mode module -p productdefault -p buildModerelease --no-daemon--no-daemon这个参数我强烈建议加上尤其在 CI 环境里。守护进程模式下偶尔会出现缓存和实际代码不一致的情况导致“明明改了代码但打出来的包还是旧的”这种让人抓狂的问题。加了这个参数启动慢一点但结果可靠。构建产物在entry/build/default/outputs/default/下面debug 包通常是entry-default-unsigned.hap或者已经签好名的版本release 包打完是entry-default-signed.hap。构建前我一般还会跑一遍清理避免旧产物残留./hvigorw clean --no-daemon5.2 签名正式包绕不过去的一关上架 AppGallery 必须用正式签名这一步纯命令行也能完成但前期准备工作要在图形界面里做一次——生成密钥和证书请求文件CSR然后在开发者后台申请发布证书和 Profile 文件。这套材料准备好之后命令行签名就是一条指令的事。签名工具的路径大概在 SDK 的 toolchains/lib 下面叫hap-sign-tool.jar。签 release 包的形态是这样java -jar hap-sign-tool.jar sign-app \ -keyAlias yourKeyAlias \ -signAlg SHA256withECDSA \ -mode localSign \ -appCertFile release.cer \ -profileFile release.p7b \ -inFile entry-default-unsigned.hap \ -keystoreFile your.p12 \ -outFile entry-default-signed.hap \ -keyPwd ****** \ -keystorePwd ******几个容易翻车的地方profile 文件里的包名必须和 app.json5 里的 bundleName 完全一致差一个字符都签不过。我因为这个卡过一次报错信息很含糊最后是逐字符对比才发现的。证书类型要对调试证书和发布证书不能混用混用会出现“签名成功但装不上”的情况。密钥库密码和密钥密码要区分p12 文件有库密码里面的密钥还有单独的密码我一开始以为是一个填错好几次。密码这种东西当然不能明文写在脚本里。我的做法是把签名相关参数抽到一个不提交到 Git 的配置文件里CI 环境用环境变量注入。5.3 装包与验证签好名的包可以用 hdc 直接装到真机上验证# 查看已连接设备 hdc list targets # 覆盖安装 hdc install -r entry-default-signed.hap # 指定应用启动 hdc shell aa start -a EntryAbility -b com.yourdomain.babyschedule # 卸载 hdc uninstall com.yourdomain.babyschedule装 release 包验证这一步千万别省。debug 包和 release 包在代码混淆、资源压缩、签名校验上都有差异很多问题只有 release 包才会暴露。我这次就遇到过 release 包下某个资源引用失效的问题debug 环境下完全正常差点带到线上。6. 上架前的准备材料比代码更容易卡人6.1 提交材料清单代码写完了上架这一关才算真正开始。个人开发者上「宝贝日程表」这类工具应用需要的材料大致是这几类材料类型具体内容我的踩坑点应用信息名称、简介、分类、标签简介不要堆关键词会被判为营销图标与截图应用图标、至少 3 张界面截图截图不能带其他平台水印隐私政策数据收集说明、权限用途必须和实际申请的权限一一对应版本说明本版本更新内容不要写“修复已知问题”这种空话资质证明视应用类型而定工具类一般不需要额外资质隐私政策这块我要重点说。很多人是从网上抄一份通用模板结果里面写了“收集位置信息”“收集通讯录”而应用实际根本没申请这些权限审核一眼就能看出不一致直接驳回。正确做法是照着module.json5里requestPermissions的列表逐条写用途一条不多一条不少。「宝贝日程表」只申请了提醒相关权限隐私政策就只写这一条干净利落。6.2 审核常见驳回点我总结了几个做工具类应用最容易撞的雷权限用途描述过于笼统。“需要该权限以提供更好服务”这种话必然被打回。改成具体场景描述比如“用于在您设定的时间点推送日程提醒”通过率立刻不一样。应用内出现了未声明的第三方服务。比如你集成了某个统计 SDK但没有在隐私政策里说明这属于典型的不一致。要么声明要么去掉。测试账号或测试数据残留。提交前把演示数据清掉别让审核员看到“测试宝宝1”“aaa”这种内容。看起来是小事但确实影响第一印象。功能与描述不符。简介里写“支持家庭共享”实际版本没做这种也会被驳回。宁可少写不要虚标。7. 排查速查表这些问题我都真遇到过7.1 构建与依赖类问题# 报大量插件或任务解析错误 rm -rf oh_modules .hvigor ohpm install ./hvigorw clean --no-daemon这类错误九成是缓存和依赖版本不一致。我现在的习惯是只要换了分支、升级了 SDK、改了oh-package.json5就先清一遍再构建比事后排查快得多。还有一个高频问题是 Node 版本不匹配症状是 ohpm 安装依赖时各种奇怪的解析失败。确认node -v是 18 LTS版本管理器切一下就行。7.2 设备与调试类问题现象排查方向解决方式hdc 找不到设备数据线、驱动、调试开关换线、确认已开启开发者模式与调试装包提示签名不一致设备上已有同名不同签名的包先hdc uninstall再装应用启动闪退看 hilog 日志hdc hilog抓崩溃堆栈定位卡片不显示卡片配置、桌面添加方式检查 module.json5 中的 form 配置抓日志我一般这么用# 过滤出本应用的日志 hdc hilog | grep BabySchedule # 保存到文件慢慢看 hdc hilog hilog.txt日志是排查问题的第一手材料比到处猜测高效得多。崩溃类问题先看堆栈最顶上那几行通常就指向问题函数了。7.3 提醒与运行时问题提醒不触发我按这个顺序排查权限是否真的授予了、代理提醒是否发布成功发布接口有返回值、notificationId是否重复、提醒时间是否已经过去过去的时间点不会触发、以及系统是否对应用做了后台限制。数据读写异常先确认数据库初始化是否在 Ability 的onCreate里就完成了。我遇到过一次偶发的“表不存在”错误最后发现是某个页面在数据库初始化完成前就开始查询了加了个初始化 Promise 的等待就解决了。这类竞态问题在异步环境里很常见写的时候就得有意识。8. 几个我真正踩过的坑和体会第一个坑是版本号。我第一次提审的时候忘了改versionCode和上一个版本一模一样后台直接拒绝提交。后来我养成了习惯每次构建 release 包之前先检查 app.json5 里的版本号把它做成构建脚本里的第一步。第二个坑是时间戳的时区问题。我用Date对象直接存时间戳本地测试一切正常但有个用户反馈跨时区后提醒时间差了几个小时。根因是我在格式化显示的时候用了本地时区而存储用的是另一个基准。现在我的做法是存储统一用 UTC 毫秒时间戳显示的时候再转成本地时区转换逻辑收口在一个工具函数里不允许各处自己格式化。第三个坑是关于命令行的“自动化惯性”。命令行确实快但不是所有事情都适合脚本化。比如签名证书的管理、隐私政策文案的修改这些涉及人工确认的环节我最后还是保留了手动步骤。工具是为人服务的追求全自动反而容易在关键节点上出事。最后再分享一个我觉得挺有用的小做法我把整个项目的命令行操作整理成了一个Makefile式的脚本集分成dev、build、release、deploy四组每组就是几条我常用的命令。这样新环境换电脑clone 下来直接能用不用回忆每条命令的参数。脚本本身很简单但它省下的时间比我最初想象的多得多。
RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进