
年初接了个很有意思的小需求给某高校做一款课表APP学生手里用的手机五花八门多数是安卓也有不少苹果还有一部分已经换上了鸿蒙设备。按老路子这种项目基本就是拆成两个原生团队排期但这次我选了 Flutter 框架做跨平台开发目标是一套 Dart 代码同时覆盖 Android、iOS 和 HarmonyOS NEXT。项目从搭环境到真正跑通鸿蒙真机中间踩了不少坑也沉淀下来一套可以照着抄的路线所以把从 0 到 1 的过程完整记录一下。这篇东西适合三类人想在鸿蒙上做跨平台应用的开发者、准备给高校或培训机构做课表工具的同学、以及正在纠结“跨平台到底选什么”的团队。文章默认你只有基础的编程概念不需要提前接触过鸿蒙 SDK也不需要已经是 Flutter 老手。1. 为什么用 Flutter 做鸿蒙课表APP1.1 一鱼三吃的现实考量给高校学生做工具最现实的问题是“终端碎片化”。这三四年的校园场景里安卓、iOS、鸿蒙并存是非常普遍的状态如果你只做安卓端苹果用户会找你只做原生双端鸿蒙用户又要单独维护。团队就我一个人工期只有两个月写三套原生代码根本不现实。跨平台方案里挑来挑去Flutter 的优势在于业务逻辑和 UI 都能复用Dart 写一遍三个平台都能跑出差不多的界面。这里我花了点时间对比过其他路线。React Native 在安卓和 iOS 上很成熟但鸿蒙适配的社区分支折腾起来比较零散插件要一个个验证Kotlin Multiplatform 能共享业务逻辑UI 还得各自写一套对一个人的项目来说工作量照样很大。Flutter 则是连渲染层都用自绘引擎不依赖系统自带控件这意味着同一个课表界面在三个系统上的一致性最高。方案逻辑复用UI 复用鸿蒙支持成熟度单人上手成本原生双端否否原生最好最高KMP部分否仍在探索中高React Native部分部分社区方案零散中Flutter完整完整有 OpenHarmony 支持中选 Flutter 还有一个隐藏好处Dart 语法本身不挑人会 Java、Kotlin、Swift 或 JavaScript 的人基本一两天就能开始写业务。我这个项目里大量时间都花在课表算法和 UI 布局上而不是花在语言学习上。1.2 技术路线的地基Flutter 跑在鸿蒙上底层的结构大概是这样工程里会有一个鸿蒙壳工程用 UIAbility 作为应用入口真正的 Flutter 引擎以动态库形式加载Dart 业务代码被编译成动态库交给引擎执行。你平时写的 Widget、状态管理、网络请求最后都运行在这个引擎之上。对应用开发者来说不需要深究引擎每一行实现但要知道两个结论第一Dart 代码不是解释执行的是预编译的所以 Flutter 鸿蒙包的体积大头在引擎和业务 so 上第二热重载机制在鸿蒙平台依然能用开发体验和安卓端几乎一致。选型之后我给自己定了一条规矩凡是第三方插件必须确认它提供鸿蒙平台的实现否则一律不引入。课表APP需要的功能其实不多网络请求、本地存储、文件导入导出这些都有跨平台方案。真要用到某个只有安卓实现的 SDK那就得考虑写平台通道自己搭桥。这个约束听起来麻烦但反而让项目保持干净没有一堆适配不了的依赖。2. 先把功能拆清楚再来写代码2.1 课表APP到底要做什么很多课表类的 Demo 只做了“静态网格 几条假数据”真拿给学生用就废了。做课表APP首先要认清一个核心它不是日历而是“以周为单位、以节次为最小时间片”的排课工具。放假调休、单双周、临时调课这些场景全都要考虑进去。我整理出来的需求清单按优先级排是这样周课表视图默认定位到当前教学周点击课程卡片能看到教师、地点、起止周、单双周等详情支持周次切换可以看整个学期的任意一周导入导出功能方便从教务系统数据一键生成课表今日课程卡片进入首页直接显示今天要上什么课冲突检测录入数据时提示两门课是否撞在同一时间格这里面最难的不是 UI是“一周到底怎么算”和“课程数据怎么建模”。很多新手写课表把日期跟星期写死结果换一个学期整个 App 就废了。正确做法是数据里存周次和星期显示时用算法把“哪一周”翻译成“哪一天”这样学期切换、补课、调休都只需要改一个开学日期。2.2 数据建模一门课长什么样课程的数据模型必须在一开始就设计好否则后面改结构会牵连解析、存储、渲染三层代码。我最终用的核心模型大致是这样enum WeekType { all, single, double } class Course { final String id; final String name; final String teacher; final String location; final int dayOfWeek; // 1 表示周一7 表示周日 final int startSection; // 起始节次1 开始 final int endSection; // 结束节次包含本身 final int startWeek; // 起始周 final int endWeek; // 结束周 final WeekType weekType; // 是否单双周 final int colorIndex; // 卡片颜色索引 final String remark; // 备注字段 }有些课表工具只存 startSection 和“节数”我建议不要这么做。因为跨行显示和冲突判断都需要精确知道“结束节次”用 endSection 直接存储最省事。dayOfWeek 和 section 都从 1 开始是为了和数组下标错开 1避免出现第 0 节这种诡异概念。为了支持多学期我把课程集合按照“学期标识”做了分组。学期标识用字符串比如2025-2026-1这样同一个 App 里可以并存上学期的数据和下学期的数据切换学期时不用清空重导。2.3 周次、节次和单双周的算法教学周算法是整个课表准确性的根基。思路不复杂以每学期第一个教学周的周一作为基准日用当前日期减去基准日得到天数差除以 7 取整再加 1就是当前周数。int currentWeek(DateTime firstMonday) { final today DateTime.now(); final start DateTime(firstMonday.year, firstMonday.month, firstMonday.day); final diff DateTime(today.year, today.month, today.day) .difference(start) .inDays; return diff 0 ? 1 : diff ~/ 7 1; }这里有个细节diff 为负数说明还没开学直接返回第 1 周比返回 0 在 UI 上更友好。另外计算时要把时间归零只用日期差否则会因为时分秒不同导致天数差一跳就少一天。课程数据最常见的格式是字符串比如1-16周、1-16周(单)、1-8,10-16周。我把字符串解析成一串布尔数组哪一周有课对应位就是 true。这样判断某周是否上课只要查数组下标就行比反复比较区间快得多也更不容易出边界 bug。Listbool parseWeeks(String raw, int totalWeeks) { final result Listbool.filled(totalWeeks, false); for (var part in raw.split(,)) { var token part.trim(); var odd token.endsWith(单); var even token.endsWith(双); token token.replaceAll(RegExp([单双周]), ); final range token.split(-); final start int.parse(range[0]); final end range.length 1 ? int.parse(range[1]) : start; for (var w start; w end; w) { if (odd w.isOdd) result[w - 1] true; else if (even w.isEven) result[w - 1] true; else if (!odd !even) result[w - 1] true; } } return result; }单双周的判断我用枚举而不是字符串匹配是为了避免出现“单双都开”“不加周次标记”这种歧义。实际使用中all 表示每周都上single 表示只在奇数周上double 表示只在偶数周上。节次时间表也不建议写死。每个学校的作息时间不一样我把节次表做成配置项例如某高校的作息是第一节 08:00-08:45、第二节 08:55-09:40中间休息十分钟。这样以后换学校只需要改配置文件不用动代码。判断课程冲突就更简单了遍历一天的课时表把每个节次占用的课存进一个二维集合同一周、同一天、同一节次如果已经有人占用就弹窗提示。3. 环境搭建与鸿蒙工程接入3.1 开发环境全景这个项目的环境分成三部分Flutter 工具链负责写 Dart 业务、编译产物DevEco Studio 负责编译鸿蒙壳工程、签名和打包 HAP鸿蒙真机负责安装测试。三者的版本需要保持匹配否则会出现“Flutter 能跑但鸿蒙侧编不过”的尴尬情况。我在项目里使用的是当前主流的 DevEco StudioAPI 12 及以上搭配 Flutter 的 OpenHarmony 兼容分支。环境变量里主要是 Flutter 的 bin 目录以及为了加速依赖下载配置的镜像地址。Flutter 的包管理服务在部分地区访问不太稳定国内开发一般会把PUB_HOSTED_URL和FLUTTER_STORAGE_BASE_URL指到官方提供的镜像站这只是正常的开发环境配置能让依赖下载更快不属于任何特殊网络操作。export PATH$PATH:$HOME/flutter/bin export PUB_HOSTED_URLhttps://pub.flutter-io.cn export FLUTTER_STORAGE_BASE_URLhttps://storage.flutter-io.cn装完 DevEco Studio 之后第一次启动会引导安装鸿蒙 SDK。这个过程比较久建议耐心等它下完同时顺手把开发者模式的 USB 调试打开后面真机调试要用。3.2 创建工程与鸿蒙壳工程创建项目的命令和其他 Flutter 项目一样我用的是flutter create --org com.example --project-name course_table course_table_app创建完成后目录里会有一个 ohos 文件夹这就是鸿蒙壳工程。它的内部结构大致包含 entry 模块entry 下又分为 module.json5、ets 页面等。你可以把 ohos 目录当成一个“启动外壳”真正业务代码都放在 lib 目录里。用 DevEco Studio 打开 ohos 目录就能识别成鸿蒙工程进而编译 HAP。我踩过的第一个坑就在这里一开始我用 DevEco Studio 直接打开了 Flutter 根目录结果工程识别混乱编译时总找不到模块。正确做法是只把 ohos 子目录给 DevEco Studio 打开。Flutter 侧的构建命令负责生成鸿蒙需要的资源配置DevEco 侧负责把资源打包成 HAP两边各管一段不要混着用。3.3 权限、签名与HAP构建鸿蒙系统和安卓类似权限需要在 module.json5 里声明。课表APP要访问网络接口所以至少要加 INTERNET 权限{ module: { requestPermissions: [ { name: ohos.permission.INTERNET } ] } }如果不加这一条真机上发 HTTP 请求会直接失败而且报错信息有时并不直观容易让人误以为是接口写错了。开发阶段还有一个小细节鸿蒙默认对明文 HTTP 有一定限制调试时可以先用 HTTPS 接口或者把接口域名加进网络安全白名单。签名配置在 DevEco Studio 的 Project Structure 里选择自动签名即可。第一次需要登录开发者账号填写组织信息之后 IDE 会帮你生成证书和描述文件并自动应用到 HAP。自动签名生成的包可以直接安装到自己的真机但不能上架应用市场上架时还要申请正式签名。构建 HAP 可以直接在 DevEco Studio 里选 Build Hap也可以在命令行执行flutter build hap。构建产物一般位于 ohos/entry/build/default/outputs 下拿到这个 hap 文件就可以安装了。4. 核心页面实现与交互细节4.1 周课表网格布局的思路课表界面最常用的设计是左侧一列显示节次时间右侧分成 7 列对应星期一到星期日。新手最容易犯的错是用 GridView 直接铺课程卡片一旦遇到一节课跨两个节次的情况GridView 的固定行列根本没法处理。我最终采用的是“Stack 绝对定位 网格线”的方案先把一天内的节次数乘以固定行高撑出网格背景再把课程卡片用 Positioned 按计算出的位置放上去。Expanded( child: Stack( children: [ // 绘制网格线和时间轴 for (var course in dayCourses) Positioned( top: (course.startSection - 1) * sectionHeight, height: (course.endSection - course.startSection 1) * sectionHeight, left: 4, right: 4, child: _CourseCard(course), ), ], ), )这个方案的好处是布局完全由数据驱动课程从第三节上到第四节卡片就自然跨两行不需要额外处理合并单元格的逻辑。sectionHeight 我取的是 64 逻辑像素加上节次时间文字视觉上刚好能放下一行课程名和一行地点。4.2 课程卡片与详情页课程卡片的信息密度要把握好。我放了课程名、教师、地点三项用白色文字配彩色背景。颜色按课程类型分配比如专业课用蓝色系、公共课用绿色系、实验课用橙色系。做颜色方案时我特意避开了红绿搭配因为要考虑红绿色盲用户。课程卡片再加一个点击事件跳转到详情页详情页里完整显示教师、地点、起止周、单双周、备注信息。今日课程展示是我后来加的也是学生反馈最好的功能。它在首页顶部根据当前周和当前星期自动过滤出当天课程按节次排序显示。这里比做一周课表更要注意空状态周末或没课的时候不要放一个空列表而是显示一句“今天没有安排课程”体验会舒服很多。4.3 本地存储、导入导出与状态管理课程数据量通常很小一个学期一般不会超过 100 条所以我没有引入完整的数据库而是用 JSON 序列化后存文件。核心流程是Course 对象转成 Map再转成 JSON 字符串写入应用私有目录读取时反向操作。这样做调试也方便文件可以直接导出来看结构化问题。导入功能我给用户两种格式CSV 和 JSON。CSV 字段顺序固定为课程名、教师、地点、星期、起始节、结束节、起始周、结束周、单双周。解析 CSV 时要注意两个点第一文件可能带 UTF-8 BOM用普通方式读取首行会出现乱码要先处理第二Excel 导出的 CSV 默认编码在国内环境经常是 GBK我建议在 App 内提供模板下载要求用户按模板导入避免编码问题扩散到用户侧。状态管理我选了 Provider。这个项目规模不大Provider 足够而且没有额外代码生成理解成本低。Riverpod 更强大但在这个体量下有点杀鸡用牛刀。状态里主要维护三个东西当前学期、当前周次、课程列表。切换周次的时候只更新一个整数状态课程列表本身不变周课表视图根据周次重新过滤数据并刷新这样性能压力和调试难度都小。5. 鸿蒙真机调试与多端联调实录5.1 从HAP安装到热重载鸿蒙真机的调试流程比安卓稍微多一步。先把设备进入开发者模式并把 USB 调试打开然后用 hdc 工具确认连接状态hdc list targets看到设备序列号后可以直接安装构建好的 HAPhdc install /path/to/entry-default-signed.hap安装完手动启动hdc shell aa start -a EntryAbility -b com.example.course_table_app开发过程中我推荐另一个更省事的模式直接flutter run指定鸿蒙设备这样不仅能自动安装还保留了热重载能力。程序跑起来之后在终端按r就能重新加载改 UI 的效果立刻能看到。手动装 HAP 之后也可以用flutter attach连上去但前提是这台设备的 HAP 是 Flutter 工具构建的调试版本正式版的 HAP 不能热重载。5.2 屏幕适配和系统交互的细节坑鸿蒙设备的屏幕比例和挖孔设计跟安卓、iOS 不完全一样适配主要靠 SafeArea 和 MediaQuery。页面根部套 SafeArea 是最基础的防护但如果状态栏区域还想保留背景色延伸就要手动读取MediaQuery.of(context).padding.top来调整内容高度。字体方面鸿蒙系统默认字体是 HarmonyOS SansFlutter 渲染中文没有大问题但如果你用了自定义字体文件要注意中文字形缺失时的 fallback 行为否则个别生僻字会显示成方框。滚动性能在鸿蒙的 Flutter 引擎上会表现得更敏感周课表这种页面不要一次性用 ListView 包几百个子项尽量用 lazy 方式构建。底部导航和手势条也容易出问题。鸿蒙有底部小横条手势指示区如果你用了 BottomNavigationBar记得把extendBody设置为 true否则底部导航上方会多出一块空白还可能导致内容被顶起。这些小东西单看都不严重但凑在一起会让 App 显得“水土不服”。5.3 三端表现差异汇总多端联调阶段我同时拿了安卓、iOS 模拟器和鸿蒙真机做对比三端的大致差异如下维度AndroidiOSHarmonyOSUI 一致性高高高字体渲染正常正常中文观感略细默认字体最接近系统风格热重载支持支持支持滚动手感默认阻尼惯性更明显更接近安卓但略有差异包体积较小较大与安卓接近实际跑下来Flutter 的自绘优势体现得很明显同一个周课表页面三端截图放到一起不仔细看基本分辨不出哪个是哪个。差异主要集中在字体渲染细致程度和滚动手感这些系统级交互上。对于课表这类工具型 App这些差异完全不影响使用用户在意的永远是“课有没有显示对”。6. 常见问题与避坑速查6.1 编译与依赖问题编译阶段的问题最磨人我在项目里先后遇到过四类。第一类是flutter pub get拉下来的插件根本不支持鸿蒙编译到一半报找不到某个符号。解决办法是在引入插件前就去查它的官方支持列表确认有 OpenHarmony 实现再装。第二类是 hdc 显示不出设备排查顺序是开发者模式是否开启、USB 调试是否允许、hdc 版本是否和本地 SDK 匹配。第三类是网络权限缺失已经在前面说过。第四类是签名问题。自动签名生成的证书有过期时间过期后 HAP 能构建但装不到真机上这时要去 DevEco Studio 里重新生成签名。这类问题最典型的特征就是“构建成功、安装失败、报签名相关错误”不要怀疑代码逻辑直接检查证书时间。6.2 运行时与UI问题运行阶段我遇到最典型的两个问题CSV 导入中文乱码和课程卡片文字溢出。CSV 乱码大概率是编码不一致导入时统一按 UTF-8 解析导出时带上 BOM 标记才能保证 Excel 打开不乱码。课程文字溢出则是因为卡片高度随节次数变化有时只有一行的高度却要显示两行文字。症状可能原因处理方式安装后闪退签名不匹配或 HAP 与设备版本不兼容重签 HAP检查 SDK API 版本网络请求失败未配置 INTERNET 权限添加权限必要时配置明文白名单中文乱码CSV 编码不统一导入统一 UTF-8导出带 BOM卡片文字溢出字号固定且未适配卡片高度使用 FittedBox 或按高度动态计算字号页面周次切换不刷新状态通知丢失检查 Provider 是否调用 notifyListeners深色模式下颜色失真直接写死颜色值通过 ColorScheme.fromSeed 动态取色课程卡片文字溢出有一个很直接的兜底方案在外层套 FittedBox让文字在空间不足时自动缩放虽然会牺牲一点可读性但至少不会出现文字被截断成半行的情况。深色模式的坑是从测试用户的截图里发现的系统切到深色主题后卡片背景和文字都还是原来的亮色刺眼。后来我把颜色统一改成从 Theme 的 ColorScheme 派生这个问题才算根治。这一版课表APP做完我最大的体会是跨平台开发的价值不在于“一次写三端”这句话有多好听而在于你能不能把业务逻辑和 UI 层彻底分开让换平台这件事只发生在壳层。Flutter 和鸿蒙结合这条路线现在已经能用但还没到无脑躺的程度选型时留好“某些插件可能要用平台通道自研”的心理预期会比较从容。最后分享两个实用经验。第一个是在开发阶段就把权限、签名、真机验证的流程固定下来别攒到发布前才跑通我身边做鸿蒙适配的朋友普遍在“最后一周才接真机”这件事上吃过亏。第二个是课程数据一定保持标准 JSON 结构哪怕你现在还没有教务接口也要按“未来可对接”的标准设计这样后面接接口、换客户端、加新功能时核心数据层不用动。课表APP这种工具型项目数据才是真正能长期复用的资产。