从 0 写一个鸿蒙 PC 应用:ArkTS 课表助手「课晓」的完整设计复盘与踩坑记录

项目地址:https://github.com/sweethaohao/kexiao-harmony
技术栈:HarmonyOS NEXT · ArkTS / ArkUI · Stage 模型 · API 18+
目标设备:鸿蒙 PC(2in1)为主,兼顾手机与穿戴


一、为什么做这个

起因很小:我自己的课表是从教务系统导出的 Word 文档。它有几个毛病——

  • 是二维表格,在电脑上得把窗口拉得很大才能看全,手机上基本没法看;
  • 只告诉你"第几节",却不告诉你"第几节是几点"。想知道"下一节课还有多久"得自己在脑子里换算;
  • 换个周次看就更乱,同一格子里可能塞了两门课,各自的上课周次还不一样。

所以我想要一个东西:打开就能看到"今天还有几节课、下一节几点上、在哪个教室",不用心算、不用缩放。

需求定了三条:

  1. 一眼看到今天——不是先看整周课表再自己找今天;
  2. 时间要算准——把"第几节"翻译成真实时刻;
  3. 作息能改——不同学校节次时间不一样,不能写死。

二、成品长什么样

主界面是"侧栏 + 内容区"的桌面布局,侧栏可折叠:

请添加图片描述

周视图——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 节的时间,而是定义了一个"时段配置":

export interface PeriodConfig {
  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 节)
}

于是上面的作息就变成了三行配置:

export const DEFAULT_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 条具体时刻:

static build(configs: PeriodConfig[]): SlotTimeTable {
  const out: SlotTime[] = [];
  let slotNo: number = 1;

  for (const cfg of configs) {
    // 每个时段从自己的 startTime 重新开始计时
    // → 「午休」「傍晚休息」天然形成,不需要额外写逻辑
    let cursor: number = DateUtil.hmToMinutes(cfg.startTime);

    for (let b = 0; b < cfg.bigCourseCount; b++) {
      if (b > 0) cursor += cfg.breakBetweenBigMinutes;   // 大课之间的课间
      for (let k = 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) { /* ... */ }
  }
  return new SlotTimeTable(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。但是!我的导入逻辑是这么写的:

const raw = JSON.parse(text);          // 解析出来的键名是原文 courseName
const name: string = raw.courseName;   // 但编译后这行变成了 raw.a

JSON 是外部数据,它的键名不可能跟着你的混淆改名。于是 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() 里,可选字段的类型收窄不可靠

我原本的视图模型是这样的:

export interface DayCell {
  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() 会经过一层转换,它不能保证在闭包里保持类型收窄的结果。

解决办法是把可选字段拍平,让每个字段都是必填:

export interface DayCell {
  kind: string;
  slot: number;
  span: number;
  // 空白格用空串 / 0 哨兵值填充,而不是 undefined
  courseName: 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 项断言

跑起来就是一条命令:

node tools/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 走就能跑起来。

如果这篇文章帮你少踩一个坑,那就值了。有问题欢迎在评论区聊。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐