从空白模板到真机运行:鸿蒙文件管家 AITest 一天开发实录

从空白模板到真机运行:鸿蒙文件管家 AITest 一天开发实录

本文记录一个 HarmonyOS 应用从需求调研、SDK 大版本迁移、编码、编译排错到真机安装运行的完整一天,以及途中踩过的十几个真实的坑。没有粉饰,没有"凭代码反推",全是聊天记录里真实发生过的事。

注:文中的服务器地址、API 路径、Key、设备标识等均已脱敏。

项目是什么

两件事,两个项目:

项目 角色 技术栈
AITest 鸿蒙客户端(本文主角) ArkTS + HarmonyOS 6.1.0(23)
FileUploadServer 远程文件服务器(已部署) ASP.NET Core 10 + PostgreSQL

服务器跑在公网(域名略),提供一套基于 API Key 的文件操作接口。客户端的目标很朴素:用户输入一个 Key 登录,然后能看文件列表、上传、下载、删除。不造后端,只对接 HTTP API。

服务器的认证方式简洁到可爱:受保护的文件接口,鉴权就靠 URL query 参数 ?key=<你的密钥>。密钥分两种--Admin(管全部文件,365 天有效,只能服务器本机创建)和 Temporary(只管自己的,24h 过期,公网可申请但依赖 IP 白名单)。

因为手机出口 IP 不固定、临时密钥又卡 IP 白名单,客户端不做密钥申请,让用户手动输入一个已有的 Admin Key 登录。这个决策从一开始就定了,省掉后面一大堆麻烦。


一、起点:调研、验证、决策

第一件事不是写代码,是先把服务器摸清楚。我用 curl 把几个关键端点都打了一遍:

测试 请求 结果 结论
HTTPS 可达性 请求文件接口(不带 Key) 401,0.13s 在线,HTTPS 正常
无效 Key 带一个乱造的 Key 请求 Unauthorized 鉴权符合预期
公开端点 请求公开文件接口 200 + JSON 匿名可用
Admin Key 带新 Admin Key 请求文件接口 200 + 完整列表 可用

服务器确认可用后,SSH 到服务器本机调密钥管理接口创建了一个 Admin Key(有效期到 2027-08-11),作为开发期的长效密钥。创建完顺手拿真实响应扒了一下 FileItem 的完整字段--比服务端文档详细,含一堆客户端用不上的内部字段(storedFileName、encryptionVersion、storageMode 等),客户端只保留 id/fileName/fileSize/contentType/uploadedAt/isPublic/publicPath 七个。

接着用一轮结构化提问跟自己确认了几个关键点:Key 来源(SSH 建 Admin)、上传来源(相册图片 + 任意文件)、下载位置(公共下载目录)、是否做密钥申请入口(不做)。


二、意料之外的迁移:API 9 -> API 23

这是整个项目最没有预料到的一段。

工程最初是 API 9 的老模板,build-profile.json5 里 compileSdkVersion: 9,代码用 @ohos.* 命名空间。我本来建议"先用 API 9 开发,代码用兼容命名空间,后续再升"。用户很干脆:"我先装最新 api"。

装完一看,不是 API 11,也不是 12,是 API 23(OpenHarmony 6.1.0.32,DevEco Studio 6.1.1.300)。一个 API 9 到 API 23 的 14 个大版本跨度。

这跨度意味着配置文件格式几乎全变:

项 API 9 旧格式 API 23 新格式
构建工具版本 hvigorVersion: 2.4.2 modelVersion: "5.0.0"
SDK 版本字段 compileSdkVersion: 9 compatibleSdkVersion / targetSdkVersion: "6.1.0(23)"(字符串)
wrapper 脚本 hvigor-wrapper.js hvigorw.js
命名空间 @ohos.* @kit.*

手动改这种配置几乎一定踩坑。于是我没硬写,而是去翻 DevEco Studio 6.1 安装目录里自带的项目模板(plugins/codegenie-plugin/previewProjectTemplate/),把模板的 build-profile.json5、hvigor-config.json5 等标准配置拿来对照着改,同时确认了 hvigor 版本是 6.24.4。照模板改,一遍过。

这一段还有两个小坑:

  • emoji 让 Edit 工具失配:改 doc/00-integration-plan.md 末尾时,Edit 报 String to replace not found,试了转义和原字符都不行--文档里有 emoji,old_string 和文件实际内容有不可见差异。最后改用 Write 整体重写。
  • Write 覆盖前必须先 Read:某次直接 Write 一个已存在的 oh-package.json5 报 File has not been read yet。因为我之前是用 cat 看的,没用 Read 工具。补一次 Read 再 Write 就过了。

三、一口气写完代码

环境就绪后,按三层架构把代码一次性铺开:

PlantUML 图表
  • ApiConfig:集中管理服务器地址和 5 个端点 URL,?key= 统一 encodeURIComponent。
  • ApiService:基于 net.http 的 JSON 请求(列表、删除、Key 校验),统一错误码映射,业务错误用自定义 ApiException 携带状态码。
  • AuthService:preferences 做 Key 的本地持久化,存/取/清。
  • FileService:封装 request.uploadFile / downloadFile,处理沙箱拷贝和进度回调。
  • Index.ets:登录页,输入 Key -> 校验 -> 存 -> 跳转,支持自动登录。
  • FileListPage.ets:列表页,上传/下载/删除/退出。

代码写完,会话结束。但没有验证编译--WSL 里 SDK 路径是 Windows 格式,DevEco 工具链也在 Windows 侧,跑不了 hvigor。我留了三个待验证风险点交给用户在 DevEco 里 sync:

  1. compatibleSdkVersion: "6.1.0(23)" 是从 SDK 的 oh-uni-package.json 推断的,没经 sync 验证。
  2. getContext(this) 在字段初始化时可能报错。
  3. 公共下载目录受 Scoped Storage 限制,当前下载到沙箱,后续优化。

这三个伏笔,后面应验了。


四、编译器教我做人:21 个 ERROR

用户在 DevEco 里一 Rebuild,炸了:21 个 ERROR、40 个 WARN,集中在三个文件。

这次最耗时的不是改代码,是逐个去 SDK 的 .d.ts 声明文件里查证每个 API 的真实形态。鸿蒙 API 在不同版本差异大、Kit 重组频繁,靠记忆写一定会错。事实证明的确如此。

坑 1:@kit.RequestKit 在 API 23 里根本不存在

FileService.ets 里 import { request } from '@kit.RequestKit' 报错,find "$SDK" -iname "*RequestKit*" 无任何结果。

连锁后果很严重:request 变成隐式 any,导致后面 UploadConfig/File/RequestData 等对象字面量全部报 arkts-no-untyped-obj-literals--8 条错误都是这一个 import 的连锁反应。

根因:@ohos.request.d.ts 文件头标注的是 @kit BasicServicesKit,request 实际由 @kit.BasicServicesKit 导出。改成 import { request } from '@kit.BasicServicesKit',8 条连锁错误瞬间消失。

坑 2:@kit.CoreFileKit 不导出 fs

import { fs } from '@kit.CoreFileKit' 失败。读声明文件发现 CoreFileKit re-export 的是 fileIo。改成 import fs from '@ohos.file.fs'。

坑 3(最有价值):修好 import 后,被 any 掩盖的隐藏 bug 会暴露

这是我自认为本次最值得的一步。修完上面两个 import,我主动追问了一句:"修好 import 后,原本被 any 掩盖的类型不匹配会暴露",然后去声明文件里逐一核验所有相关 API 签名,挖出 4 处原本根本不报错的隐藏 bug:

  1. fs.OpenMode.WRITE 不存在--命名空间成员只有 READ_ONLY / WRITE_ONLY / READ_WRITE / CREATE / TRUNC / APPEND。原代码没报错纯粹因为 fs 是 any。改 WRITE_ONLY。
  2. DownloadConfig 没有 filename 字段--声明里只有 filePath?: string。原代码 filename: fileName 被 any 放过。改 filePath。
  3. DownloadTask.on('complete', ...) 回调无参数--声明是 callback: () => void,原代码写 (downloadPath: string) => ... 签名不匹配。
  4. DownloadTask.on('fail', ...) 参数是 number--声明是 (err: number) => void,原代码写 (err: BusinessError) => ...。

后两条要重写 awaitDownloadComplete 为 Promise<void>,回调对齐 SDK 签名,并移除不再用的 BusinessError 导入。

这 4 个 bug 修完,用户重新 Build 才不会再撞新错。编译器之前"放过"它们,不是因为对,是因为类型信息缺失。这个教训我记下了:修完一个类型 import,一定要回头扫一遍同文件里所有用到该类型的调用。

其他小坑(罗列)

  • PhotoSelectResult.uris 不存在,实际属性是 photoUris。
  • DocumentViewPicker.select() 直接返回 Promise<Array<string>>,没有 uris 包装。
  • Progress 的 .style() 已废弃(@useinstead type),改用构造器 Progress({ type: ProgressType.Linear })。
  • List 的对齐方法是 .alignListItem(),不是 .align()。
  • ArkTS 严格模式不允许 throw e(arkts-limited-throw),要 throw e as Error。
  • resp.result 是 string | Object | ArrayBuffer 联合类型,赋给 string 要 as string。

五、取回钥匙,准备上手机

编译过了之后,用户要 Key。文档里记录了 Key 的有效期,但明确写"key 值不入库"。于是 SSH 上服务器,在服务器本机调密钥管理接口查到了之前创建的那个 Admin Key 的值,跟文档记录吻合。顺手存进项目 memory,下次直接取,不用再翻聊天记录。

接着是打包。当时 signingConfigs: [] 是空的,模拟器能跑但真机必须有签名。用户没配过自动签名,我给了流程:DevEco 里 Automatically generate signature(需华为开发者账号)-> 开 USB 调试 -> Build 出 -signed.hap -> hdc install。

这里用户问了个好问题:"我构建了 hap 和 app,应该用哪个?" 查产物发现两个都是 -unsigned,还没签名。解释:hap 是单模块安装包,hdc install 直接装手机;app 是上架应用市场的整包格式,装不了。自用一律 hap。

还有一个:"所以一定要先插上手机对吗?"--配签名本身不需要手机(云端生成证书),但 HarmonyOS 的 Debug Profile 会绑定允许调试的设备 UDID。如果配签名时没连手机,Profile 里没设备 UDID,debug 包装上去会报设备不匹配。所以建议先连手机再配签名,一次把 UDID 塞进 Profile。


六、伏笔应验:版本号报错

晚上继续,用户报"发布到手机失败了"。读构建日志,错误 00303015 Configuration Error。读 build-profile.json5 发现:

compatibleSdkVersion: "6.1.1(24)"   // 比 target 高
targetSdkVersion: "6.1.0(23)"

这就是上午留的第一个伏笔--版本号是推断的。用户中间可能动过版本号,把 compatible 调到了 6.1.1(24),但本地只装了 API 23 的编译 SDK(6.1.1 仅有系统镜像用于模拟器),没有 API 24 的 ets SDK。规则要求 compatible <= target <= 已装SDK,所以必须把 compatible 降回 "6.1.0(23)",让 23 <= 23 <= 23 成立。

上午推断的风险,晚上如实应验。


七、装机:hdc 的路径坑与无线调试

版本号修好,构建出一个已签名的 hap(153K)。hdc list targets 确认设备已连。

然后撞了 hdc 的一个别扭坑:

hdc install "E:/Code/.../entry-default-signed.hap"
# 报:no such file or directory, path:E:\Code\...\E:/Code/.../entry-default-signed.hap

带盘符的正斜杠绝对路径,被 hdc 拼到了 cwd 前面。改用相对路径(cwd 是项目根)就过了:

hdc install "entry/build/default/outputs/default/entry-default-signed.hap"

后来用户想切无线调试。hdc shell ifconfig wlan0 查到手机 WiFi 局域网 IP,用 hdc tconn <手机IP>:<端口> 连上,无线 hdc install 也成功。


八、功能开发:批量上传与长按菜单

装上能跑了,用户提了四个功能:图片批量上传、前端图片直接预览、长按操作、不能预览的显示大文件图标占位。交互方案选了"纯长按菜单"(去掉右侧下载/删除按钮,列表更干净),批量上限 99 张。

坑 4:ArkTS 严格模式拒绝 Array<Button> 赋值给元组

promptAction.showActionMenu 的 buttons 形参类型是元组 [Button, Button?, Button?, Button?, Button?, Button?](1-6 个,至少 1 个必填)。我一开始用 Array<Button>(可能为空),ArkTS 严格校验直接拒:

ArkTS Compiler Error ... Target requires 1 element(s) but source may have fewer.

先试 as 断言,但不确定严格模式放不放行。最后改成确定可行的写法:用固定元组字面量,按"是否图片 × 是否公开"4 种组合构造 buttons;actions 用平行数组,按下标映射动作。这样字面量直接匹配 SDK 的元组类型。

这里用户明确不满了:"有报错了,你不能先上网查一下他的 sdk 用法再写吗"。说得对。之后改成先查 @ohos.promptAction.d.ts 类型定义,再动代码。

坑 5(最折腾):我删了 IDE 缓存,把 DevEco 也搞崩了

排查命令行 hvigorw.bat assembleHap 失败时,我执行了 rm -rf ".../project_caches/.../workspace",想清缓存重试。结果命令行没修好,反而把 DevEco 用的 hvigor workspace 缓存也删了。DevEco 重建了 workspace 目录,但 @ohos/hvigor 包目录是空的(没 package.json、没 index.js),第二次 DevEco 构建直接报:

worker[14] encounters error: Cannot find module '@ohos/hvigor'
BUILD FAILED in 2 s 807 ms

自伤。修复过程相当曲折,卡在 WSL 调 Windows 命令的引号传递上:

  1. WSL cp 从 DevEco 内置目录复制 -> 太慢(drvfs 跨盘复制通病),300s 超时没完。
  2. cmd.exe /c robocopy ... -> bash 单引号传给 cmd.exe 时双引号没被正确解析,robocopy 把 cwd 拼到路径前,参数全错,目标 0 文件。
  3. bash 双引号 + \" 转义 -> cmd.exe 引号规则和 bash 冲突,仍失败。
  4. 最终 powershell.exe -NoProfile -Command "robocopy '源' '目标'" -> 成功,上万文件全到位。

教训两条:不要乱删 IDE 的缓存目录;WSL 调 Windows 命令传参,PowerShell 最稳。

坑 6:显示层把模板插值渲染没了,差点冤枉好代码

这个坑特别有意思,我写这篇博文时又亲手踩了一遍。

开发长按菜单时,我读 ApiConfig.ets,看到下载/删除的 URL 模板里有一段插值,显示出来像是 id 参数根本没用上、URL 是坏的。我当时信心满满地写了一整段修复方案。

幸好多了个心眼,用 od -c 做了字节级检查:

$ sed -n '27p' entry/src/main/ets/services/ApiConfig.ets | od -An -c
   ...   `   $   {   A   p   i   C   o   n   f   i   g   .   A   P   I
   _   F   I   L   E   S   }   /   $   {   i   d   }   `   ,   ...

字节是 24 7b 69 64 7d = $ { i d }。代码本来就是正常的 $glm_5.2_ark_toC 模板插值,是 Read/sed 这类工具的显示层把插值渲染没了,显示成误导形态。撤销修复计划,代码无需改动。

最妙的是,写这篇博文前我再次用 Read 读这个文件,又看到了被误导的形态,差点第二次上当。od -c / hexdump 字节级验证才救了我。教训:对可疑的字符串污染,先 od -c 看字节再下结论。


九、最后一个 bug:中文文件名

功能都写完,用户报"ui 上中文显示有问题"。我第一反应是查文件编码--UTF-8,无 BOM,中文字节正常,没问题。用户一句话点醒:"是 url 解码的问题"。

grep 全项目,只有 ApiConfig.ets 对 key 做了 encodeURIComponent,全文没有任何 decodeURIComponent。根因:picker 返回的 URI 里,中文文件名是百分号编码(如 %E4%B8%AD),extractFileName 提取文件名时没解码,上传到服务器再回显到 UI,就成了编码串。

修复:extractFileName 提取后加 decodeURIComponent;新增 displayName() 方法,用 try-catch 防误解码(兼容历史已上传的编码文件名);列表项、长按菜单标题、删除确认、下载进度 4 处全部改用 displayName(file)。

// 从 URI 提取文件名并解码
extractFileName(uri: string): string {
  const idx = uri.lastIndexOf('/');
  const raw = idx >= 0 ? uri.substring(idx + 1) : uri;
  try { return decodeURIComponent(raw); } catch (e) { return raw; }
}

// 显示用文件名:兼容历史已上传的百分号编码文件名
displayName(file: FileItem): string {
  try { return decodeURIComponent(file.fileName); } catch (e) { return file.fileName; }
}

十、复盘:几条教训

一天下来,从空白模板走到真机可用,几条真切的教训:

  1. 不靠记忆写 SDK 调用,查 .d.ts 声明。鸿蒙 Kit 重组频繁(RequestKit 并入 BasicServicesKit、fs 不在 CoreFileKit),记忆里的写法换个 SDK 版本就错。先查声明再写,比写完被编译器打回来快。

  2. 修完一个类型 import,回头扫同文件所有调用。any 会掩盖类型不匹配,import 修好后这些隐藏 bug 才暴露。主动挖,比被动等下一次 Build 报错强。

  3. 不乱删 IDE 缓存目录。命令行构建和 IDE 共用 workspace 缓存,删了命令行没修好,还把 IDE 拖下水。WSL 调 Windows 命令传参用 PowerShell 最稳。

  4. 可疑字符串污染,先 od -c 看字节。显示层可能把模板插值渲染没,看着像 bug 其实是渲染假象。字节不会骗人。

  5. 版本号别靠推断。compatibleSdkVersion 这种字段,从 oh-uni-package.json 推断出来一定要 sync 验证,否则早晚应验。

  6. 真机签名先连手机再配。Debug Profile 绑设备 UDID,先连手机再配自动签名,一次到位,少重签。

  7. Key 不入库,SSH 现查。敏感凭证不入仓库,文档只记有效期,需要时 SSH 到服务器查,查完存项目 memory。


现状与展望

目前 App 在真机上跑通了完整闭环:输入 Key 登录(自动登录)-> 文件列表(图片缩略图/非图片 emoji 占位)-> 从相册/文件批量上传(99 张,带进度)-> 下载(带进度)-> 长按菜单(预览/下载/删除/复制公开链接)-> 退出登录。错误码统一处理,401 自动清 Key 回登录页。

待优化:

  • 下载目前落到应用沙箱 filesDir,用户在文件管理器里不直接可见;API 11+ 可用 saveas 或 FilePicker 中转保存到公共目录。
  • Key 明文存 preferences,高安全场景可换 huks 加密存储。
  • 缩略图每次都用下载接口拉全图,大图费流量,可加本地缓存或服务端缩略图接口。
  • 正式发布到应用市场需要申请 release 签名/发布证书,目前是 debug 签名自用。

一天,从 Hello World 到真机可用。鸿蒙 @kit.* 系统能力封装本身不难,最费时间的是 SDK 大版本迁移和那几轮编译排错。希望这篇实录对你有帮助。

📥 导出 Markdown