从 0 写一个鸿蒙 PC 应用:ArkTS 课表助手「课晓」的完整设计复盘与踩坑记录
项目地址:https://github.com/sweethaohao/kexiao-harmony
技术栈:HarmonyOS NEXT · ArkTS / ArkUI · Stage 模型 · API 18+
目标设备:鸿蒙 PC(2in1)为主,兼顾手机与穿戴
一、为什么做这个
起因很小:我自己的课表是从教务系统导出的 Word 文档。它有几个毛病——
- 是二维表格,在电脑上得把窗口拉得很大才能看全,手机上基本没法看;
- 只告诉你"第几节",却不告诉你"第几节是几点"。想知道"下一节课还有多久"得自己在脑子里换算;
- 换个周次看就更乱,同一格子里可能塞了两门课,各自的上课周次还不一样。
所以我想要一个东西:打开就能看到"今天还有几节课、下一节几点上、在哪个教室",不用心算、不用缩放。
需求定了三条:
- 一眼看到今天——不是先看整周课表再自己找今天;
- 时间要算准——把"第几节"翻译成真实时刻;
- 作息能改——不同学校节次时间不一样,不能写死。
二、成品长什么样
主界面是"侧栏 + 内容区"的桌面布局,侧栏可折叠:
周视图——13 节 × 7 天的网格,跨节次的课用合并单元格的方式占多行,今天所在列高亮:
今明日程——只回答"接下来要做什么",不显示整周:
这里有个我自己比较满意的设计:当今天的课全部上完之后,它会自动切到"明天"。上图中是周三傍晚,周三的课已经结束,于是页面顶部标题从"今天"变成了"明天",直接显示周四的课。用户不需要手动翻页,也不会看到一片空白。
作息设置——把节次表直观展示出来,每一节对应几点几分:
首次打开是空态,不预置任何数据,由用户自己决定"载入示例课表"还是"导入自己的课表":
三、技术选型:为什么是 ArkTS + ArkUI
鸿蒙上做应用,官方路线就是 ArkTS + ArkUI(声明式 UI),这个没什么可犹豫的。真正需要想的是结构怎么切。
我给自己定了一条硬规矩:
纯逻辑代码里,不许出现任何
@ohos.*/@kit.*的 import。
凡是碰系统的(读文件、拿上下文、开数据库)全部集中在pages/和将来的database/里。
为什么?两个理由:
第一,能测。课程数据怎么按周次过滤、星期几加节次怎么换算成真实时间、今天该显示哪一天的课——这些是纯函数,不依赖设备。把它们和系统 API 隔开之后,我可以在电脑上用 Node 直接跑测试,不用等模拟器、不用点界面。这个决定后来救了我很多次(见第七节)。
第二,好迁。现在数据存在内存里,以后要换成relationalStore数据库、再以后可能要同步到云端——只要逻辑层的输入输出契约不变,换存储层不用动业务代码。
最终目录结构:
entry/src/main/ets/ ├── model/ # 数据模型 + 默认配置(纯类型、纯常量) │ ├── Models.ets │ └── Defaults.ets ├── common/ # 通用工具(纯函数) │ ├── DateUtil.ets │ └── SlotTimeTable.ets ├── service/ # 业务逻辑(纯逻辑) │ ├── ScheduleService.ets │ └── ImportService.ets └── pages/ └── Index.ets # 唯一需要碰系统的地方整个工程约 2400 行 ArkTS,其中Index.ets一个人就占了 1000 行左右——这也说明 UI 代码的"密度"确实比逻辑代码低。
四、核心设计:把「第几节」翻译成「几点几分」
这是整个项目里我觉得最值得说的一块。
问题:节次是个嵌套结构
我参考的作息是这样的:
上午 08:00 开始 ├─ 大课 1 ─┬─ 小课 1 08:00-08:45 │ └─ 小课 2 08:45-09:30 │ (课间 15 分钟) └─ 大课 2 ─┬─ 小课 3 09:45-10:30 └─ 小课 4 10:30-11:15 下午 12:40 开始(结构同上午)→ 15:55 下课 晚上 17:00 开始 = 1 节大课 + 3 节小课 → 21:30 下课 共 13 节注意几个特征:
- "大课"里的小课是连着的(08:00→08:45→09:30,中间不休息);
- 大课之间才有课间(09:30 → 09:45 隔了 15 分钟);
- 上午和下午之间的空档(11:15 → 12:40)是午休,不是"课间";
- 晚上结构还不一样:只有 1 节大课,后面又跟了 3 节散的小课。
如果按"每节课固定 45 分钟 + 间隔 15 分钟"来算,结果全错。必须把"时段 → 大课 → 小课"这个三层嵌套建模出来。
方案:用配置描述结构,让代码去展开
我没有硬编码 13 节的时间,而是定义了一个"时段配置":
exportinterfacePeriodConfig{key:PeriodKey;// 'morning' | 'afternoon' | 'evening'label:string;// '上午'startTime:string;// '08:00' —— 这个时段的开始时刻bigCourseCount:number;// 有几节大课smallPerBig:number;// 每节大课由几个小课组成smallSlotMinutes:number;// 小课时长breakInBigMinutes:number;// 大课内部小课之间的间隔(通常是 0)breakBetweenBigMinutes:number;// 大课之间的课间trailingSmallCount:number;// 时段末尾追加的零散小课(晚上那 3 节)}于是上面的作息就变成了三行配置:
exportconstDEFAULT_PERIOD_CONFIGS:PeriodConfig[]=[{key:'morning',label:'上午',startTime:'08:00',bigCourseCount:2,smallPerBig:2,smallSlotMinutes:45,breakInBigMinutes:0,breakBetweenBigMinutes:15,trailingSmallCount:0},{key:'afternoon',label:'下午',startTime:'12:40',bigCourseCount:2,smallPerBig:2,smallSlotMinutes:45,breakInBigMinutes:0,breakBetweenBigMinutes:15,trailingSmallCount:0},{key:'evening',label:'晚上',startTime:'17:00',bigCourseCount:1,smallPerBig:2,smallSlotMinutes:45,breakInBigMinutes:0,breakBetweenBigMinutes:15,trailingSmallCount:3}];然后由SlotTimeTable.build()把它"展开"成 13 条具体时刻:
staticbuild(configs:PeriodConfig[]):SlotTimeTable{constout:SlotTime[]=[];letslotNo:number=1;for(constcfgofconfigs){// 每个时段从自己的 startTime 重新开始计时// → 「午休」「傍晚休息」天然形成,不需要额外写逻辑letcursor:number=DateUtil.hmToMinutes(cfg.startTime);for(letb=0;b<cfg.bigCourseCount;b++){if(b>0)cursor+=cfg.breakBetweenBigMinutes;// 大课之间的课间for(letk=0;k<cfg.smallPerBig;k++){if(k>0)cursor+=cfg.breakInBigMinutes;// 大课内部(通常 0)out.push(SlotTimeTable.makeSlot(slotNo,cursor,cfg.smallSlotMinutes,cfg.key,cfg.label));cursor+=cfg.smallSlotMinutes;slotNo++;}}// 时段末尾的零散小课if(cfg.trailingSmallCount>0){/* ... */}}returnnewSlotTimeTable(out);}这个设计的好处是:换一个学校,只需要换配置数组,一行代码都不用改。
比如某学校上午只有一节大课、每节大课 3 个小课、小课 40 分钟、大课间 20 分钟——配置里改四个数字,整张表自动重算。甚至"某节课时间特别奇怪"这种情况,我也留了fromSlots()兜底,允许用户手写死每一节。
节次号是跨时段连续递增的(上午 1-4、下午 5-8、晚上 9-13),这样和教务系统里"第一节……第十三节"的说法完全对齐——因为教务系统本来就是连续编号的。
五、踩坑记录
这部分的每一条都是我真实撞过的,写出来给后来人少走点弯路。
坑 1:开了混淆,导入功能静默失效
上架前我打开了 release 混淆:
-enable-property-obfuscation -enable-toplevel-obfuscation -enable-filename-obfuscation -enable-export-obfuscation打完包一测——导入课表功能直接失效,导进来 0 条数据。
排查后发现原因很隐蔽:-enable-property-obfuscation会把属性名改掉,比如courseName被重命名为a。但是!我的导入逻辑是这么写的:
constraw=JSON.parse(text);// 解析出来的键名是原文 courseNameconstname:string=raw.courseName;// 但编译后这行变成了 raw.aJSON 是外部数据,它的键名不可能跟着你的混淆改名。于是raw.courseName找raw.a,永远拿到undefined,导入静默失败——不报错,就是没数据。
解决办法是在obfuscation-rules.txt里显式保留这些契约字段名:
-keep-property-name schemaVersion source entries courseName courseCode startSlot endSlot weeks weeksRaw campus room teacher weekday slot教训:只要你的代码和"外部世界"有基于名字的契约(JSON 字段、字符串 key、反射调用),开混淆就必须把它们加进保留清单。而且改导入格式的时候一定要同步改这份清单,否则又是一个静默 bug。
我最后的验证方式是:把 release 包真机装上去点一遍导入,确认渲染结果和 debug 完全一致,才算数。开启 release 构建与混淆后,签名包体从377 KB 降到 232 KB(约 38%),代价就是这份清单要维护好。
坑 2:hvigor 不让你在配置文件里写明文口令
build-profile.json5里的签名配置长这样:
"signingConfigs": [{ "name": "default", "material": { "storeFile": "./signature/kexiao.p12", "storePassword": "123456", // ← 这样写会直接把构建搞崩 "keyAlias": "openharmony application release", "keyPassword": "123456" } }]填明文会报:
The length of the storePassword or keyPassword ... is less than 32我一开始以为是"密码太短",改成 32 位以上的明文——还是报错。翻了 hvigor 插件的源码才明白:它要的是自己那套 AES-GCM 加密后的十六进制密文,长度刚好 ≥32,明文不管多长都不认。
这道设计其实是合理的(防止你把密码明文提交进 Git),但文档里说得不清楚。它的加密链路是:
material/{fd,ac,ce} 三个随机块 异或 ↓ PBKDF2-SHA256(10000 次) rootKey ↓ AES-128-GCM 解密 工作密钥 ↓ AES-128-GCM 解密 真实口令因为我的签名材料需要能一键重建(不然别人 clone 下来构建直接失败),我干脆把这套算法复刻了一遍,写成了一个脚本,并且用 DevEco 自带的解密器反向验证过——算是彻底搞明白了。
顺带一提:命令行编译出来的 HAP 是未签名的,hdc install必然失败,报install sign info inconsistent (9568332)。第一次遇到很容易去怀疑证书有问题,其实先看signingConfigs是不是空的更有效。
坑 3:ArkUI 的build()里,可选字段的类型收窄不可靠
我原本的视图模型是这样的:
exportinterfaceDayCell{kind:string;// 'course' | 'empty'slot:number;span:number;course?:DayCourse;// 空白格没有课程数据}然后在build()里这么用:
if(cell.kind==='course'&&cell.course!==undefined){Text(cell.course.item.courseName)// 期望这里已经收窄了}逻辑上没问题,实际上编译能过、运行时崩。ArkUI 的声明式build()会经过一层转换,它不能保证在闭包里保持类型收窄的结果。
解决办法是把可选字段拍平,让每个字段都是必填:
exportinterfaceDayCell{kind:string;slot:number;span:number;// 空白格用空串 / 0 哨兵值填充,而不是 undefinedcourseName:string;room:string;color:string;entryId:number;// 0 = 没有}用哨兵值代替undefined,看着是"倒退",但在 ArkUI 里是更稳的写法。同样的处理也用在了「当前正在上的课 / 下一节课」这两个字段上——不需要的时候 entryId 填 0。
坑 4:Scroll+Column会把内容挤到底部
今明日程页一开始用的是:
Scroll() { Column() { // 若干张课程卡片 } }内容比屏幕短的时候,卡片会被顶到容器最底部,上方留一大片空白,很难看。
原因大概是Scroll内部的布局策略在小内容量时不做顶部对齐。改成List+ListItem承载之后立刻正常了。
经验:在 ArkUI 里凡是"数量不定的条目列表",直接用List,别用Scroll包Column。
坑 5:最狠的一个坑,是我自己挖的
开发早期,为了让模拟器一跑起来就有数据看,我把自己的真实课表 JSON 复制进了entry/src/main/resources/rawfile/,并且在启动时无条件加载它。
当时觉得挺方便。直到有人问我"打包上架之后,测试数据不会一起发出去吧?"
我去查了一下,冷汗下来了:
resources/目录是会被编译进 HAP 安装包的。
也就是说,如果那样上架,每一个下载这个应用的人,打开就会看到我的课表——包括 14 门课程名、14 位老师的真名、教室号、课程代码。更糟的是文件里还留着一行"source": "/mnt/g/project/myP/我的课表.docx",把我本机的文件路径也一起带出去了。
而且这份数据既不在数据库里、也不在 preferences 里,它在安装包里——卸载重装都没用,只要文件在包里就永远在。
顺带这也解释了一个我早先困惑的现象:为什么"清除应用数据"看不出"首次导入"的差别?因为数据根本不是"导入"来的,是随包携带、启动即灌的。
处理方法分两层:
- 开发期:真实课表移到工程外(
.gitignore兜底),包内只放一份完全虚构的示例数据,保留"装上就能看到效果"的便利; - 上架期:首次打开走空态 + 导入引导,包里不含任何样例数据。
这里我还犯过一个更细的错:写第一版示例数据时,我图省事复用了自己真实的两个课名。已经全部换成编造的(「微积分(上)」「网页设计基础」这类)。
结论:个人数据不要放进resources/。这个目录的语义就是"要分发出去的东西"。
六、没有模拟器也能测:让 Node 直接跑 ArkTS 逻辑
因为我在第三节定了"纯逻辑不 import 系统 API"的规矩,逻辑层其实是一堆普通的 TypeScript。
Node 22 原生支持类型擦除执行.ts文件,于是就有了这个自测方案:
tools/selftest/ ├── run.mjs # 把 .ets 里的纯逻辑抽出来转成 .ts,再用 Node 跑 └── cases.ts # 70 项断言跑起来就是一条命令:
nodetools/selftest/run.mjs输出:
通过 70 项,失败 0 项覆盖的内容包括:
- 节次表生成——13 节分别落在哪个时刻,"09:30→09:45 是大课间、08:45→09:30 不是"这类边界;
- 日期换算——
周次 + 星期 + 节次→ 真实日期; - 周次过滤——第 1 周有哪些课、第 13 周有哪些课(用不连续周次的数据验证);
- 今明日程——"今天课已上完 → 自动看明天"这条规则;
- JSON 导入——去重、校验、告警,以及"同格多课"「跨节次」这些边界。
这个方案最大的好处是反馈快。改完逻辑几十毫秒就出结果,不用打包、不用装到设备上、不用点界面。UI 层才需要真机验证。
有一个小细节要提醒:run.mjs在做.ets→.ts转换时,必须把纯类型导入拆成import type,否则运行时会报"找不到导出"。
七、后面还想做什么
现在这版是能用的,但离"完整"还差得远。按我自己的优先级排:
1. 数据持久化(最该做的)
现在导入的数据只在内存里,关掉应用就没了。下一步接relationalStore落库,同时把"是否已导入过"这个标记存下来——这样"首次导入"才成为一个真正可观察的状态,而不是每次打开都从零开始。
2. 作息参数可编辑
现在作息是只读展示,参数写死在Defaults.ets里。既然节次表已经是"配置驱动"的,把它接到设置页、允许用户改并持久化,是很自然的一步。
3. 提醒 / 闹钟
"上课前 15 分钟提醒"是这个应用最实用的功能之一。需要确认reminderAgent在 2in1(PC)形态上的支持情况——这个得实际测,不能想当然。
4. 手机桌面服务卡片(Form)
这个是我比较想做的一块。课表类应用的最佳形态其实不是打开应用,而是"不用打开应用"——把"今天剩下几节课、下一节几点"做成桌面卡片,抬眼看一眼就够了。鸿蒙的 ArkTS 卡片(Static / Live 卡片)应该能撑住这个场景,但卡片的能力和主应用是分开的一套,得单独写。
5. 穿戴设备适配
工程里的deviceTypes其实已经声明了phone/2in1/wearable,但目前只在 2in1 上真机验证过。手表屏幕太小,13 节 × 7 天的网格肯定摆不下,得换成完全不同的交互——我倾向于做成"今天 + 下一节"的极简单页。
6. 教务系统直连
现在导入还得先在电脑上把 docx 解析成 JSON。理想状态是应用内直接粘一个教务系统链接就能导入。这个难度不小(各校系统差异大、还涉及登录态),但用户体验的收益也最大。
7. 上架
打包上架本身还有一件事没做:现在用的签名是本地 OpenHarmony 调试证书,profile 里绑定了我这台模拟器的 UDID——也就是说,这个包别人的设备根本装不上。正式上架必须换成 AppGallery Connect 签发的发布证书和发布 profile。
八、小结
这个项目技术上不算复杂,但麻雀虽小五脏俱全:数据建模、时间换算、UI 布局、构建配置、签名、混淆、隐私边界,每一样都踩了点坑。
我最想分享的三条经验:
第一,把纯逻辑从 UI 里切出来。这不只是为了"代码好看"——它直接决定了你能不能在没有设备的情况下验证业务规则。node tools/selftest/run.mjs这一条命令,比我点一百次模拟器的价值都大。
第二,配置驱动胜过硬编码。"节次表"这种每个学校都不一样的规则,用配置描述结构、让代码去展开,比写死 13 条时间强太多。
第三,也是最重要的:想清楚哪些目录会被分发出去。resources/会被打进安装包,Git 仓库会被所有人看到。个人数据放在哪里,不是一个"以后再说"的问题。
项目地址:https://github.com/sweethaohao/kexiao-harmony
工程里带了完整的示例数据、70 项逻辑自测、以及一键部署脚本(编译 → 本地签名 → 安装 → 截图),clone 下来按 README 走就能跑起来。
如果这篇文章帮你少踩一个坑,那就值了。有问题欢迎在评论区聊。