如果你也想挑战一次“不点鼠标”的鸿蒙开发这篇应该对你有用。第一次在命令行里敲完创建鸿蒙项目的命令时我盯着黑底白字的输出看了好几秒——不是惊叹是怀疑自己有没有把命令敲对。毕竟网上几乎所有教程的第一步都是“打开 DevEco Studio点击 Create Project”。但我的情况比较特殊我需要把「宝贝日程表」这个项目的构建流程嵌进团队现有的自动化流水线里图形界面反而成了阻碍。所以从第一个空白工程开始我就一直用 DevEco 命令行工具链完成创建、编译、打包、签名最后把上架 AppGallery Connect 的版本也全部用命令行走了下来。「宝贝日程表」是一个纯本地存储的轻量应用核心用户是家长。它解决的是带孩子过程中最琐碎的信息管理问题下次疫苗什么时候打、周末的绘画课几点开始、幼儿园体检日期是哪天、今晚几点该提醒孩子刷牙睡觉。这类日程往往零散、变化频繁但又不能忘。市面上通用日历应用虽然能做提醒但针对“儿童事务”的模板几乎没有。这个项目不大但麻雀虽小五脏俱全——有列表页、添加页、日期选择、本地持久化、通知提醒、真机调试、签名打包、上架审核。对想完整走一遍鸿蒙应用生命周期的开发者来说是一个非常合适的样本工程。1. 放弃图形界面不是装酷CLI 到底解决了什么实际问题1.1 我的使用场景与选型判断先说实话纯用命令行开发鸿蒙应用日常写代码的效率不一定比 IDE 高。特别是 ArkTS 页面级开发时DevEco Studio 的预览器、代码补全、断点调试确实方便。但我这次坚持 CLI 优先原因有三第一构建流程需要可重复。我所在的小团队最近把发布流程往持续集成上迁移要求所有产物必须由流水线构建禁止“谁本地能打包谁就手动点”。如果整个项目只有 IDE 能构建流水线就废了。DevEco 的命令行工具链hvigorw 构建、ohpm 依赖管理、hdc 设备调试刚好能把从代码到 hap 包的完整链路变成一串可执行命令。第二远程开发和轻量协作需要。连续几天我都在一台配置不高的旧笔记本上远程操作DevEco Studio 这类基于 IntelliJ 的 IDE 启动一次要占掉好几个 G 内存而命令行工具链在纯终端环境下跑得很轻松。第三产物差异可控。IDE 界面上的按钮背后其实也是一条条命令但界面上容易误触一些“自动配置”。直接用命令行我心里清楚每一步做了什么出了问题也知道去哪查。如果你是独立开发者、刚开始学鸿蒙或者只是想做一个自用的小工具不必强求 CLI。但如果你想进团队协作、自动化发布或者搞一些批量脚本任务CLI 这条路早晚要趟。1.2 CLI 工具链的组成和准备工作很多人以为“DevEco CLI”是一个单独下载的程序实际上它是一整套命令行工具的集合。我这次用到的主要有下面几个工具职责类比对象devecostudioCommand Line Tools工程创建、项目信息管理类似 Android 的 avdmanager / sdkmanager 那类入口hvigorw构建与打包Hvigor 的 wrapper 脚本类似 Gradle wrapperohpm鸿蒙的包管理器安装三方依赖类似 npm / pubhdc设备连接、安装、日志、文件操作类似 adbhap-sign-toolhap 签名工具类似 apksigner准备阶段最容易被忽略的是环境变量。DevEco Studio 安装之后命令行工具并不会自动出现在 PATH 里。我装的是 5.0 版本的 DevEco Studio工具链默认在安装目录的command-line-tools子目录下其中bin目录里放着 devecostudio 和 hdcsdk目录下还有 ohpm、hvigor 等。我在~/.bashrc里加了一行export PATH/opt/DevEco-Studio/command-line-tools/bin:$PATH工具链依赖 Node.js 环境版本要求比较新建议直接用 LTS 版本。检查一下node -v npm -v devecostudio --version如果devecostudio --version能正常输出版本号说明入口没问题。这一步卡住的话后续全部免谈。我见过不少人在这一步报 “command not found”十有八九是环境变量没指对目录。2. 命令行建工程目录结构、hvigor 和 Stage 模型2.1 从零创建「宝贝日程表」工程准备就绪后我用一条命令把工程骨架拉起来了。不同版本的 DevEco CLI 参数格式会有一点差异动手之前先跑一次帮助命令确认这是经验devecostudio project create --help我实际执行的是devecostudio project create \ -n BabySchedule \ -p com.example.babyschedule \ -m stage \ -t empty参数含义-n指定工程名-p指定 bundleName也就是鸿蒙应用的包名后面上架要用必须提前想好-m stage选择 Stage 模型-t empty选择空模板。鸿蒙目前的推荐模型是 Stage老的 FA 模型在新项目里不建议再碰。这里我刻意没有引入网络、地图等复杂依赖因为「宝贝日程表」定位就是纯本地应用模板越干净越好。创建完成之后先别急着写代码马上跑一次构建验证工具链是否通顺hvigorw assembleHap如果第一次构建就报错九成是 ohpm 依赖没装。鸿蒙工程默认有一个oh-package.json5文件类似前端项目的package.json需要先执行ohpm install这里补充一个我踩过的坑ohpm install必须在工程根目录也就是oh-package.json5所在目录执行否则它会提示找不到配置文件。而且安装完依赖后构建时如果还是报找不到模块先检查oh_modules目录是否生成。命令行工具不会像 IDE 那样自动帮你同步依赖每次新增三方依赖后都得手动跑一次ohpm install。2.2 必须搞清楚的 hap、hsp、har 三种产物工程建好后在正式开发之前我建议你先花十分钟理解鸿蒙工程的产物类型。因为后续打包、上架、动态更新都跟它有关。产物类型全称作用我这次的使用情况HAPHarmonyOS Ability Package应用的可安装包包含 Ability 和资源最终上架的单元主包包含全部功能HARHarmonyOS Archive静态共享包代码和资源会随模块打包没有用到小项目不需要拆HSPHarmonyOS Shared Package动态共享包可独立加载没有用到「宝贝日程表」是一个再简单不过的单模块应用所以我的工程里只有一个entry模块最终产生一个 hap 安装包。如果你做的是中大型应用多个模块之间共享代码时把通用逻辑抽成 HAR 或 HSP 是更合理的做法但小项目强行拆分会增加很多看不到的维护成本。2.3 工程骨架与 EntryAbility 的启动流程用 CLI 创建出来的工程目录结构大致如下BabySchedule/ ├── AppScope/ │ ├── app.json5 # 应用级配置 │ └── resources/ # 应用级资源 ├── entry/ │ ├── src/main/ │ │ ├── ets/ │ │ │ ├── entryability/ │ │ │ │ └── EntryAbility.ets │ │ │ └── pages/ │ │ │ └── Index.ets │ │ ├── resources/ # 模块级资源 │ │ └── module.json5 # 模块配置 ├── build-profile.json5 # 构建配置文件 ├── hvigorfile.ts # hvigor 入口 ├── oh-package.json5 # 依赖声明 └── hvigorw # 构建脚本打开EntryAbility.ets你会看到onWindowStageCreate方法它是 UI 加载的入口。里面有一行关键代码windowStage.loadContent(pages/Index, (err, data) { ... });这行代码决定了应用启动后第一个加载的页面是pages/Index。我后面新增的日程列表页、添加页都是通过路由从 Index 跳转过去的。类似 Android 的MainActivity和 iOS 的rootViewController这个入口理解了整个应用的导航关系就顺了。3. ArkTS 页面开发实战日程列表、添加弹窗与状态刷新3.1 首页信息架构与组件拆分「宝贝日程表」的首页设计原则是“一屏看清今天要干什么”。我把它拆成三个区域顶部日期概览显示今天是公历几号、星期几中间今日日程列表按时间先后排列底部一个悬浮“添加”按钮点击弹出添加日程的对话框页面虽然简单但我坚持把列表项和弹窗都拆成独立组件。拆组件不是炫技是为了让列表渲染时的状态管理更清晰。ArkTS 里组件就是一个被Component装饰的 struct通过Builder可以在父组件里复用一段 UI 逻辑这点和前端 Vue 的插槽思路有点像。我的首页Index.ets大致结构是Entry Component struct Index { State scheduleList: ScheduleItem[] []; build() { Column() { DateHeader() List({ space: 12 }) { ForEach(this.scheduleList, (item: ScheduleItem) { ListItem() { ScheduleItemView({ item: item }) } }, (item: ScheduleItem) item.id) } // 悬浮添加按钮 Button(添加日程) .onClick(() { // 弹出自定义对话框 }) } } }3.2 State 状态管理与 ForEach 列表渲染ArkTS 的语法基础是 TypeScript但加了很严格的门槛。最直观的限制是不能用any类型对象字面量必须显式声明类型解构赋值在部分场景下也不被允许。初写 ArkTS 时TS 写多了的人最容易在类型上被编译器反复教育。状态管理最常用的是State装饰器。它装饰的变量一旦变化页面会自动刷新。比如用户添加了一条新日程我只需要把新对象 push 进scheduleList列表就会重新渲染。这个响应式特性和前端框架很像但有个小陷阱——State是“浅观察”如果你直接修改数组某个下标里的对象属性比如this.scheduleList[0].title 新标题;界面大概率不会刷新。正确做法是创建一个新对象再整体替换或者用一个不可变更新的方式const newList this.scheduleList.map((item, index) { return index 0 ? {...item, title: 新标题} : item; }); this.scheduleList newList;ForEach渲染列表时第三个参数是键值生成器必须保证每条数据有一个稳定且唯一的 key。我用的是数据库表的主键id而不是数组下标。因为如果用下标删除中间某条数据后后面的数据 key 全变了容易导致列表动画错乱或状态残留。这个点是在实际测试里发现的当时列表删除之后被删项下面几个条目的展开状态仍然存在排查了半天最后定位就是 key 的问题。3.3 自定义弹窗与日期选择器添加日程我选择用CustomDialog。最初我图省事直接在当前页面里用if变量控制一个Dialog组件显示隐藏。但做了两版后发现弹窗内部有自己的输入状态如果和页面混用State父页面稍微刷新一下弹窗内容就被重置了。拆成独立的自定义弹窗组件后状态隔离干净很多。自定义弹窗的做法是CustomDialog struct AddScheduleDialog { controller: CustomDialogController; State title: string ; State date: string ; // yyyy-MM-dd 格式 State time: string ; // HH:mm 格式 onConfirm: (item: ScheduleItem) void () {}; build() { Column() { TextInput({ placeholder: 日程名称, text: this.title }) .onChange((value: string) { this.title value; }) DatePicker({ start: new Date(2020-1-1), end: new Date(2030-12-31), selected: new Date() }).onChange((value: Date) { this.date formatDate(value); }) // 确认、取消按钮 } } }时间格式处理是另一个容易踩坑的地方。DatePicker返回的是Date对象如果你直接用toString()得到的是英文格式和中国用户习惯的2025-06-01差很远。所以我封装了一个formatDate工具函数用getFullYear、getMonth 1、getDate逐个拼出字符串月和日不足两位时补零。这个工具函数很小但几乎所有页面都会用到。4. 日程数据不丢的方案首选项与关系型数据库怎么选4.1 数据模型与场景分析写页面容易让数据在应用重启后还在才是关键。「宝贝日程表」的数据涉及两类配置类默认提醒时间、上次选中的分类、用户是否看过引导页业务类日程本身每条包含标题、日期、时间、类型疫苗/课程/体检/作息、备注第一类数据量小、结构简单用首选项Preferences就够了本质是个 KV 存储。第二类是核心业务数据以后可能还要做搜索、按月份批量查询需要更完整的数据库能力我选择关系型数据库RelationalStore。两者都是 HarmonyOS 官方提供的本地存储能力不需要接任何后端服务。数据模型定义如下export interface ScheduleItem { id: number; title: string; date: string; // 日期 yyyy-MM-dd time: string; // 时间 HH:mm category: string; // 疫苗 / 课程 / 体检 / 作息 note: string; remindBefore: number; // 提前提醒分钟数默认 30 }4.2 轻量首选项实现配置与最近选择首选项的使用非常像 Android 的 SharedPreferences。初始化时先拿到一个 Preferences 实例然后通过get/put读写键值对。它的特点是轻量、异步、跨页面访问方便。我封装了一个ConfigManager来统一管理配置项import { preferences } from kit.ArkData; const PREF_NAME baby_schedule_config; export class ConfigManager { private static pref: preferences.Preferences | null null; static async init(context: Context) { this.pref await preferences.getPreferences(context, PREF_NAME); } static async getDefaultRemindMinutes(): Promisenumber { const value await this.pref?.get(defaultRemindMinutes, 30); return value as number; } static async setDefaultRemindMinutes(minutes: number) { await this.pref?.put(defaultRemindMinutes, minutes); await this.pref?.flush(); } }注意最后那个flush()。put之后数据只存在内存里必须调用flush()才会真正落盘。我最初漏了这一步调试时发现配置重启就丢查了文档才发现是刷盘的问题。这一点和 Android SharedPreferences 的apply/commit是一个道理。4.3 关系型数据库存储完整日程业务数据用关系型数据库。HarmonyOS 的relationalStore底层是 SQLite所以写过 SQL 的人会非常亲切。建表时我用了一些约束保证数据质量CREATE TABLE IF NOT EXISTS schedule ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, date TEXT NOT NULL, time TEXT NOT NULL, category TEXT DEFAULT other, note TEXT DEFAULT , remind_before INTEGER DEFAULT 30 )数据库操作我封装在ScheduleDatabase类里。查询某一天的日程是最核心的操作import { relationalStore } from kit.ArkData; const STORE_CONFIG: relationalStore.StoreConfig { name: baby_schedule.db, securityLevel: relationalStore.SecurityLevel.S1 }; export class ScheduleDatabase { private static store: relationalStore.RdbStore | null null; static async init(context: Context) { this.store await relationalStore.getRdbStore(context, STORE_CONFIG); await this.store.executeSql(CREATE_TABLE_SQL); } static async queryByDate(date: string): PromiseScheduleItem[] { const predicates new relationalStore.RdbPredicates(schedule); predicates.equalTo(date, date); predicates.orderByAsc(time); const resultSet await this.store!.query(predicates); // 遍历 resultSet 转换成 ScheduleItem[] } }几个细节值得注意securityLevel决定数据的安全等级纯本地无敏感数据的应用用S1就够了等级越高对设备环境要求越严格。查询条件用RdbPredicates而不是字符串拼接 SQL这样可以避免注入问题语义也更清晰。RdbPredicates支持链式调用equalTo后面加orderByAsc非常自然。插入和删除就不过多展开了本质就是对应 SQL 的封装。但我要多说一句关于“日期字段用什么类型”的选择。我最终选择用TEXT存储yyyy-MM-dd字符串而不是存时间戳原因很简单这个应用所有查询都是按天来的字符串精确匹配和排序都足够而且可读性强。如果你要做跨时区、跨日期的复杂计算再考虑时间戳不迟。5. hdc 真机调试、自动签名与 hap 打包的完整链路5.1 hdc 连接真机与常用调试命令开发过程中我用模拟器跑通了基本功能但涉及通知提醒、日历权限这些能力时模拟器还是和真机有差异所以中后期基本都在真机上调试。hdc 是鸿蒙的设备调试工具用法和 adb 非常像。常用命令先列出来hdc list targets # 查看已连接设备 hdc install entry/build/default/outputs/default/entry-default-signed.hap hdc uninstall com.example.babyschedule hdc shell hilog | grep BabySchedule # 查看应用日志 hdc file send local remote # 推送文件到设备我排障时最常用的是hdc shell hilog。如果应用启动崩溃日志里会直接打印异常栈。有一次日程列表反复闪烁定位到是一个组件没有设置唯一 key 导致的渲染复用问题就是靠日志里的 warning 发现的。命令行开发虽然没有图形化的 Logcat 面板但hilog配合 grep 其实更高效还能把日志重定向到文件慢慢分析。5.2 签名文件与 Profile 配置真机安装 hap 必须签名上架更是必须用正式签名。鸿蒙的签名链路涉及四个文件.p12密钥库文件包含密钥对.csr证书签名请求文件.cer签名证书由 AGC 颁发.p7bProfile 文件描述应用与签名证书的关联关系新手建议先用 DevEco Studio 的自动签名功能登录华为账号、勾选自动签名IDE 会帮你完成大部分文件生成。但我的目标是在命令行环境里也能复现签名所以手动走了一遍。流程大致是生成 p12 和 CSR把 CSR 上传到 AppGallery Connect 申请证书再基于证书生成 Profile。这些文件准备好之后我用hap-sign-tool完成签名java -jar hap-sign-tool.jar sign-app \ -keyAlias babyschedule \ -signAlg SHA256withECDSA \ -mode localSign \ -appCertFile release.cer \ -profileFile release.p7b \ -inFile entry-default-unsigned.hap \ -outFile entry-default-signed.hap \ -keystoreFile release.p12 \ -keystorePwd your-keystore-password \ -keyPwd your-key-password这条命令看着长但每一步都有明确的输入输出。我把这些路径和密码抽到了环境变量里避免把密钥写死在构建脚本中。5.3 命令行构建与产物验证所有代码写好之后完整的命令行构建命令是ohpm install hvigorw assembleHap --mode module -p productdefault构建产物输出在entry/build/default/outputs/default/目录下。未签名和已签名的 hap 文件都在这个目录里签名后的文件可以通过安装命令验证hdc install entry-default-signed.hap如果安装时报签名错误先检查签名文件的bundleName是否和工程里的bundleName一致再检查 Profile 文件是否过期。Profile 文件是有效期限制的调试 Profile 有效期比较短过期后重新生成一个即可。这个问题我在开发后期遇到过一次当时自动签名一切正常但 CI 环境手动签名的包一直安装失败排查了半天发现是 CI 服务器上用的 Profile 文件是三个月前生成的已经过期了。6. 上架 AppGallery Connect容易漏掉却卡审核的细节6.1 创建应用与包名一致性上架的第一步是在 AppGallery Connect简称 AGC后台创建应用。创建时需要填写应用名称、分类、语言、包名等信息。这里最容易被忽视的是包名一致性AGC 里填写的包名必须和工程里app.json5中的bundleName完全一致一个字符都不能差。我在工程里用的 bundleName 是com.example.babyschedule。如果你要正式上架建议把com.example改成你自己拥有的域名反写因为example这类保留字在部分审核场景下会被打回。虽然这不是硬性规定但审核人员每天看大量应用保留字包名多少会给审核带来不太专业的印象。创建应用时还需要上传应用图标。图标要求 PNG 格式且不能有透明通道。我最初用设计稿导出的图标自带圆角透明背景第一次提交审核就被打回了。后来我按照要求的尺寸重新导出一份不带透明通道的图标才顺利通过。别忘了在module.json5里也配置图标否则 AGC 后台显示正常真机桌面图标却是默认的。6.2 隐私声明、权限说明与版本发布上架时大家通常只关注代码功能实际上审核花费时间最多的往往是各种声明和资料。这次「宝贝日程表」虽然只申请了通知权限和日历权限但审核后台仍然要求提供隐私政策网址应用权限用途说明用户协议可选但建议提供权限说明一定要和代码里申请的权限一一对应。我一开始在module.json5里申请了ohos.permission.KEEP_BACKGROUND_RUNNING想用来做后台提醒后来发现这个权限对纯本地日程应用来说很容易被审核怀疑“后台常驻的合理性”干脆去掉改成用户主动开启通知权限。去掉之后权限说明变得异常简单审核也顺利了不少。所以我的建议是只申请你真正用到的权限并在说明里写清楚每个权限的用途。关于版本号module.json5和build-profile.json5里的versionCode和versionName要提前规划好。versionCode是单调递增的数字上架新版本时必须比当前版本大。我用的是日期加序号的方式比如第一次上架 100001第二次 100002简单不会乱。6.3 我这次遇到的两个审核相关小坑第一个坑是“儿童相关应用的内容适龄声明”。应用名和简介里有“宝贝”两个字功能又是围绕儿童日程审核人员要求额外确认应用内容是否适合儿童。这其实是一个比较严谨的机制。我花了半天时间补充了内容分级问卷在应用简介里写清楚“本应用仅用于家长管理日程信息不面向儿童收集任何个人信息”后来就顺利通过了。第二个坑是截图。上架需要提供至少一张应用运行截图。我偷懒直接在模拟器上截了几张图结果图片分辨率不符合要求被拒了一次。后来老老实实连接真机在 2K 分辨率下截取了几张包含首页、添加弹窗、日程明细的清晰截图并补上了中英文双语界面截图如果目标市场包含海外的话。截图这事看似无关紧要卡在审核流程里很消耗时间提前按后台要求的分辨率和数量准备是省时间的做法。上架提交之后就是等待审核。审核周期通常一两天期间如果收到“需要补充信息”的通知不要急着提交新版本先在 AGC 后台的“版本管理”里按要求补充材料效率更高。最后再分享一点实际体验。整条流程跑下来我发现纯命令行开发鸿蒙应用不仅是可行的而且对工程的掌控感更强。DevEco Studio 在我这里变成了一个可选的“工程预览器”只在需要看布局效果时才打开。如果你平时也用 IDE 开发我建议至少把hvigorw assembleHap这条命令跑通。因为总有一天你会发现自动化流水线、远程构建服务器、同事的机器都需要它。到那时你已经提前把路铺好了。