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

> 发布时间: 2026-08-12 08:46

# 从空白模板到真机运行:鸿蒙文件管家 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
@startuml
skinparam componentStyle rectangle
skinparam shadowing false

package "UI 层 (ets/pages)" {
  [Index.ets\n登录页] as Login
  [FileListPage.ets\n文件列表页] as List
}

package "服务层 (ets/services)" {
  [ApiConfig] as Cfg
  [ApiService] as Api
  [AuthService] as Auth
  [FileService] as File
}

package "鸿蒙系统能力 (@kit.*)" {
  [NetworkKit\nnet.http]
  [BasicServicesKit\nrequest]
  [CoreFileKit\npicker / fs]
  [ArkData\npreferences]
}

cloud "FileUploadServer\n(公网,域名略)" as Server

Login --> Auth : 存取 Key
Login --> Api : 校验 Key
List --> Api : 列表/删除
List --> File : 上传/下载
Api --> NetworkKit
File --> BasicServicesKit
File --> CoreFileKit
Auth --> ArkData
NetworkKit ..> Server : HTTPS + ?key=
BasicServicesKit ..> Server
@enduml
```

- **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)`。

```typescript
// 从 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 大版本迁移和那几轮编译排错。希望这篇实录对你有帮助。
