我在摸索HarmonyOS 6的时候,花了整整两个晚上才把“私有文件到底该怎么安全地交给别的应用”这件事绕明白。更要命的是UnionID认证——文档里三个词分开都能查到,但串起来做一个完整案例,几乎全靠试错:签名指纹不对调不通、Code换Token时秘钥放错位置、文件URI发过去对方应用打不开……一个坑接一个坑。
这篇文章就是一趟完整的学习记录,我拿一个个人记账应用当练习背景,把“私有化存储文件访问控制”和“UnionID认证”从原理到代码到排障全部过了一遍。看完之后,你至少能明白三件事:HarmonyOS 6下应用私有目录里的文件到底怎么隔离、怎么授权给别人访问;同一开发者名下的多个应用怎么通过UnionID识别同一个用户;以及认证和文件权限这两个模块如何串成一个完整的业务闭环。对刚接触HarmonyOS 6、或者正在做鸿蒙应用里用户鉴权和数据隔离的开发者,这趟笔记应该能帮你少走不少弯路。
1. 项目整体设计:先用一个记账应用把需求钉死
1.1 需求拆解:为什么要学私有化存储和UnionID
刚开始看“私有化存储文件访问控制”这个词,很容易被唬住,说白了就是一件事:应用在本地生成的文件,默认只能自己访问,不能随便被别的应用读走,更不能被系统公共目录里的“脏数据”干扰。HarmonyOS 6沿用并强化了应用沙箱隔离机制,每个应用都有一块独立的空间,里面再按用途分成files、cache、temp这几个区域,互相之间看着像同一个目录,实际上物理隔离。
我在记账应用里遇到的具体需求是:用户每天记账产生的账单JSON文件,要保存到应用私有目录下的files文件夹;当用户想把账单导出给别人看时,应用要临时把这个文件授权给系统的文件管理应用或者其他应用;同时,同一用户在手机和平板两端登录,后端要认出来“这是同一个人”。
这就牵扯出两个关键技术点:一个是文件访问控制,什么时候允许别人读,什么时候必须拒绝;另一个是UnionID认证,不同App之间怎么共用一套用户标识。把需求钉死之后,整个学习路线就清楚了:先搞定存储和权限,再搞定跨应用用户识别,最后把两者串起来。
1.2 技术选型:为什么后端用AGC,认证选UnionID
后端我选的是AGC(AppGallery Connect)云函数,主要原因是它和HarmonyOS生态天然打通,不用自己再单独维护一套后端服务。云函数里可以直接写Node.js逻辑,方便做Access Token换取、UnionID校验这类操作,省去搭服务器的成本。当然,如果你手头有现成的服务端,用Spring Boot、Express也完全没问题,只要按照OAuth 2.0的授权码模式走就行。
认证方案选择UnionID而不是OpenID,是我在这次项目里收获最大的一个认知。OpenID是“同一个App内识别用户”的标识,同一用户在同一个应用里不变;但如果是同一个开发者名下的A应用和B应用,OpenID就不通了。UnionID则是在开发者账号体系下全局唯一的,只要你名下多个应用接入同一套华为账号能力,返回的UnionID就是同一个,这样后端做用户统一识别就非常省事。对记账这种可能需要多端同步、多应用联动的场景,UnionID是更合理的底座。
1.3 整体架构:本地文件、认证、后端三者怎么协作
整个项目最简架构可以拆成三层:
- 客户端:HarmonyOS 6应用,负责调用账号登录、拿授权码,同时负责本地账单文件的读写和分享;
- 服务端:AGC云函数,负责接收授权码、调用华为OAuth接口换取Access Token和用户信息,返回UnionID,并给客户端下发业务Token;
- 存储层:本地私有目录负责缓存账单文件,后端数据库负责绑定“UnionID-用户配置-多端同步数据”。
客户端登录成功后,把后端返回的UnionID当作业务系统里的主键,和本地账单文件绑定;下次用户导出文件时,文件头部会写入带UnionID信息的标识字段。这一步做完,前端的文件和后端用户的关联关系就闭环了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 私有化存储与文件访问控制:沙箱规则必须先搞清楚
2.1 HarmonyOS 6的应用目录到底怎么划分
HarmonyOS 6的应用沙箱目录,按官方设计可以粗略分成几类:files目录放长期保存的用户文件,比如导出的账单JSON;cache目录放缓存,比如网络图片、临时计算结果,系统空间吃紧时可能被清理;temp目录就更临时了,应用退出后基本没指望保留;还有数据库、SharePreferences这类结构化数据,也都在私有空间里凭应用自己的权限访问。
我第一次上手时犯了个傻,以为把文件写进files目录后,随便哪个应用都能通过“文件管理”看到。实测下来发现,系统文件管理应用默认根本看不到应用私有目录里的东西。这个隔离是操作系统层面做的,不是靠开发者自觉,所以对于小体量应用,隐私数据放在私有目录里本身就比放在公共媒体库更稳。
| 目录 | 典型用途 | 是否可被其他应用直接访问 | 是否可能被系统清理 |
|---|---|---|---|
| files | 用户长期数据、导出文件 | 否,需临时授权 | 否 |
| cache | 缓存、中间结果 | 否 | 是 |
| temp | 临时文件 | 否 | 是 |
| 数据库/首选项 | 结构化本地数据 | 否 | 否 |
2.2 文件访问控制的几种方式
现在关键问题来了:既然私有目录默认隔离,那怎么把文件安全地交出去?我在项目里验证过三种方式。
第一种是系统文件选择器/分享面板。用户主动触发导出操作时,通过系统分享能力把文件URI交给对方应用。这种方式安全度最高,因为授权动作由用户显式触发,系统会帮我们管理临时授权。记账应用里的“导出账单”按钮,我最终就用的是这条路。
第二种是临时授权URI。通过文件管理API生成一个带有权限的文件URI,通过Want传给目标应用,并声明只允许对方访问指定的这一个文件。这种方式适合应用之间合作比较明确、双方包名都已知的场景。官方文档里对这种临时授权有专门的权限模型,核心就是“最小化授权,用完即收”。
第三种是应用组共享(App Group)。如果你名下有多个应用,想让它们共享一块私有数据空间,可以在AGC开通“应用组”能力,然后在module.json5里配置组ID。加入同一组之后,应用可以直接访问共享沙箱目录,不需要每次跳转授权。这个能力比较适合全家桶式产品,我的记账应用暂时没用到,但知识点值得记下来。
2.3 权限声明与代码示例:把文件写进私有目录
实操环节,我先在module.json5里按需声明权限。注意,访问自己应用私有目录并不需要额外权限,但如果你要读系统媒体库或者公共下载目录,就必须声明ohos.permission.READ_MEDIA这类权限,并且要动态申请。
json5复制// module.json5 关键字段示例
{
"module": {
"name": "entry",
"requestPermissions": [
{
"name": "ohos.permission.READ_MEDIA",
"reason": "需要读取媒体文件用于账单附件展示",
"usedScene": {
"abilities": ["EntryAbility"],
"when": "inuse"
}
}
]
}
}
动态申请权限的代码段我也一并贴出来,避免只配置不申请导致接口返回错误:
typescript复制// 动态申请权限
import { abilityAccessCtrl, Permissions } from '@kit.AbilityKit';
import { common } from '@kit.AbilityKit';
const context = getContext(this) as common.UIAbilityContext;
const atManager = abilityAccessCtrl.createAtManager();
const permissions: Array<Permissions> = ['ohos.permission.READ_MEDIA'];
try {
const result = await atManager.requestPermissionsFromUser(context, permissions);
// 检查 result.authResults 是否为 0 表示授权成功
} catch (err) {
console.error(`requestPermissionsFromUser failed, code is ${err.code}, message is ${err.message}`);
}
然后把账单文件写入私有目录,我用的核心API是@kit.CoreFileKit里的fileIo。写成文件的同时,我先顺手调一次fs.statSync确认文件确实落地了,这步在排障时能省很大力气。
typescript复制// 创建并写入私有文件
import { fileIo as fs } from '@kit.CoreFileKit';
import { common } from '@kit.AbilityKit';
import { JSON } from '@kit.ArkTS';
const context = getContext(this) as common.UIAbilityContext;
const filePath = `${context.filesDir}/bill_202503.json`;
const data = { version: 1, owner: "test_user", records: [] };
const file = fs.openSync(filePath, fs.OpenMode.READ_WRITE | fs.OpenMode.CREATE);
fs.writeSync(file.fd, JSON.stringify(data));
fs.closeSync(file);
// 验证文件存在
const stat = fs.statSync(filePath);
console.info(`bill file size = ${stat.size}`);
2.4 文件访问控制的策略建议
如果只是把文件写进私有目录就完了,那还不叫“访问控制”。真正要在项目里落实的是策略。我根据自己的实践整理了三条经验:
第一,默认全部拒绝。哪怕应用之间的协作方是自家应用,也不要默认开放读权限。每次授权都走一次明确的URI授权,出问题时可追溯。
第二,敏感内容必须加密。文件访问控制管的是“谁能访问”,管不了“设备被root之后数据被拷走”这种场景。账单里的金额、账户信息属于敏感数据,我在序列化之后又做了一层AES加密,写进文件的是密文。这样即使临时授权被误发,对方拿到的也是一堆密文。
第三,授权记录要清理。临时授权不会永远有效,但作为开发者,最好在业务层也维护一张“授权文件清单”,记录当前发给过谁、什么时候发的,用户主动撤销时可以一并处理。
3. UnionID认证:跨应用用户识别的关键一环
3.1 UnionID 到底解决了什么问题
UnionID这个概念,我一开始也总觉得和OpenID没什么区别。实际写代码时才体会到差异:记账应用要支持平板和手机双端登录,这两个端安装的是同一个App包吗?如果是同一个包名,OpenID还能通用;但如果以后我又做了一个同步插件App,这两个应用都要识别同一个用户,OpenID就彻底对不上了。
UnionID的定义是:同一个开发者账号下,同一个用户在所有应用里保持不变的唯一标识。也就是说,不管用户在你的记账应用登录,还是在你的日记应用登录,只要你都接入了华为账号服务,后端拿到的UnionID都是同一个。这比让用户每次重新注册、再自己绑定手机号的做法要省事得多。
| 标识类型 | 同一应用内 | 同开发者名下不同应用 | 主要用途 |
|---|---|---|---|
| OpenID | 不变 | 变化 | 单应用内用户识别 |
| UnionID | 不变 | 不变 | 多应用/多端用户统一 |
| 用户自建ID | 不变 | 设计而定 | 业务数据库主键 |
3.2 认证流程设计:授权码模式
UnionID的获取流程,我采用的是标准的OAuth 2.0授权码模式。客户端不直接拿Access Token去请求用户信息,而是先拿到一次性Authorization Code,再把Code交给后端;后端用Code和App的ClientSecret去换取Token,最后用Token调用户信息接口,拿到UnionID。
为什么后端一定要掺一脚?因为ClientSecret相当于是应用的密码,放在客户端里等于裸奔。我之前看过有人图省事,把ClientSecret写在前端资源文件里,打包之后直接被人从APK里扒出来,整个账号体系的用户数据都可能泄露。正确的做法是:客户端只拿Code,后端统一管理密钥,后端返回给客户端的是一个自己签发的业务Token,而不是华为的Access Token。
code复制登录流程梳理:
用户点击“华为账号登录”
→ 客户端拉起华为账号授权页
→ 用户同意,客户端拿到 Authorization Code
→ 客户端把 Code 传给自有后端/AGC云函数
→ 后端用 Code + ClientSecret 换 Access Token
→ 后端调用用户信息接口,解析出 UnionID
→ 后端生成本业务 Token 返回客户端
→ 客户端保存 Token,完成登录
3.3 在DevEco Studio里跑通登录
接入账号登录的细节比较多,我先说最关键的配置:签名证书指纹必须和AGC后台保持一致。我在项目里第一次调登录,始终报“invalid token”,查了半天,最后发现是GC后台填的SHA256指纹和本地签名证书不一致。DevEco Studio里的签名信息可以在File > Project Structure > Signing Configs里查看,每次更新证书后,AGC后台也要同步更新。
拉起登录的代码,本质上是调用账号能力,通过getAccessToken获取授权码,然后把授权码传给后端。代码逻辑大致如下:
typescript复制// 登录按钮处理
import { BusinessError } from '@kit.BasicServicesKit';
import { hilog } from '@kit.PerformanceAnalysisKit';
@State authCode: string = '';
async function requestLogin(): Promise<void> {
try {
const loginParams = {
scopes: ['profile', 'openid'],
forceLogin: false
};
const accessToken = await getAccessToken(loginParams);
this.authCode = accessToken.authCode ?? '';
// 将 this.authCode 发送给后端换取 UnionID
} catch (err) {
const e = err as BusinessError;
hilog.error(0x0000, 'LoginDemo', `login failed code=${e.code}, msg=${e.message}`);
}
}
这段代码有几个坑要提醒:一是scopes里不要乱加权限,加得越多,用户拒绝的概率越大;二是forceLogin建议设成false,不然用户已经登录过还要再输一次密码,体验很差;三是authCode是一次性的,后端换完Token后就失效了,如果后端接口失败需要重新发起登录。
3.4 后端校验UnionID和绑定用户
后端我在AGC云函数里写了一个登录接口,接收客户端传来的Authorization Code,然后向后端OAuth地址发起请求。Node.js环境下我习惯用axios做HTTP调用,关键代码可以这样写:
typescript复制// AGC 云函数示例(TypeScript)
import axios from 'axios';
export async function loginByCode(code: string): Promise<any> {
const tokenUrl = 'https://oauth.example.huawei.com/oauth2/v3/token';
const userInfoUrl = 'https://oauth.example.huawei.com/oauth2/v3/userinfo';
// 1. code 换 token
const tokenRes = await axios.post(tokenUrl,
new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: process.env.CLIENT_ID ?? '',
client_secret: process.env.CLIENT_SECRET ?? ''
})
);
const accessToken = tokenRes.data.access_token;
// 2. token 换用户信息
const userRes = await axios.get(userInfoUrl, {
headers: { Authorization: `Bearer ${accessToken}` }
});
const unionid = userRes.data.unionid;
// 3. 这里可以查库、创建会话、生成业务Token
return { unionid, nickname: userRes.data.nickname };
}
后端拿到UnionID后,不要直接把它发到前端就完事。我的做法是:服务端维护一张用户表,以UnionID为唯一索引,首次登录时创建用户记录,后续登录直接更新最后登录时间;然后服务端自己生成一个业务Token给客户端,Token里带上用户ID,过期时间设为7天。这样客户端的每次请求都校验业务Token,而不是反复携带华为的Access Token。
4. 完整实操记录:把认证和文件权限串起来
4.1 环境准备:DevEco Studio、AGC、真机一个都不能少
我用的开发环境是DevEco Studio(HarmonyOS 6 SDK),模拟器跑账号登录总是不太稳,所以直接上了真机。AGC后台需要先创建项目和应用,然后开通认证服务,拿到Client ID。这个环节最容易出问题的就是签名指纹,我在3.3里已经提到了,这里再强调一遍:每次重新生成签名证书,都必须同步回填到AGC后台,否则登录必失败。
本地真机调试时,建议打开“开发者选项”里的“USB调试”,用hdc工具连接设备。HarmonyOS 4.2之后对调试工具做了一些整合,实测下来hdc配合DevEco的日志面板已经完全够用。我的习惯是先用hdc list targets确认设备连接正常,再跑项目。
4.2 创建私有文件并验证沙箱隔离
这一步是最扎实的基础验证。我在应用首页放了一个“写入账单文件”按钮,点击后调用2.3里的代码,把一段测试JSON写入files目录,然后再通过hdc把文件拉到电脑上看。
bash复制# 查看应用私有目录
hdc shell
# 进入应用的沙箱目录,需要先通过 ps -ef 找到应用进程,再用 profile 拿到沙箱路径
# 或者直接使用 DevEco Studio 的 Device File Browser 查看
实操下来,用DevEco Studio自带的Device File Browser最直观,不需要敲命令就能看到data/app/el2/100/base/{包名}/files/下面的文件。我写进去的bill_202503.json就规规矩矩躺在那里,而且这个目录在文件管理器里是看不到的,完美达到了“私有化存储”的效果。
4.3 通过临时授权完成文件分享
模拟“导出账单给别人看”的场景,我走的是系统分享面板。点击导出按钮,把私有文件的URI和MIME类型封装进Want,然后拉起系统的分享界面,让用户自己选择接收方。应用之间不需要知道对方是谁,授权由用户和系统共同管理。
typescript复制import { Want } from '@kit.AbilityKit';
import { common } from '@kit.AbilityKit';
function shareBillFile(context: common.UIAbilityContext, filePath: string) {
const uri = `file://${filePath}`;
const want: Want = {
action: 'ohos.want.action.sendData',
uri: uri,
type: 'application/json',
parameters: {
'ohos.extra.param.key.contentTitle': '我的账单'
}
};
context.startAbility({ ...want, action: 'ohos.want.action.sendData' });
}
实测中有一个小坑:URI的格式必须严格匹配系统识别规则,我之前忘了写file://前缀,目标应用收到之后直接打不开。另外投递类型最好用application/json,而不是text/plain,不然对方拿到手可能当成纯文本处理,格式全乱掉。
4.4 UnionID登录链路联调
登录链路我按3.2的流程完整跑了一遍。客户端登录按钮触发授权码获取,然后我把授权码用云函数地址发到后端。第一次联调时后端返回了401,排查发现是云函数的鉴权配置默认打开了“需要认证”,也就是外部请求必须带着AGCHTTP签名才能调通。
这个坑很隐蔽。我最后把云函数的鉴权模式改成了“允许匿名访问”,同时在云函数里自己校验客户端传来的业务Token,这样既方便客户端直连,又没有完全放弃安全控制。如果你是个人学习项目,强烈建议在云函console配置里找到“身份验证”,选择允许匿名访问,然后靠业务参数或者API Key做一层兜底。
4.5 组合场景验证:认证状态与文件权限协同
整条链路都通了以后,我做了最后一项验证:把用户登录成功后返回的UnionID写入本地私有文件,模拟“当前登录用户”和“导出的账单归属人”一致。我故意用一个测试账号A登录后写入文件,再用账号B登录后尝试读取同一个文件,预期结果是读取失败,因为文件权限仍然由沙箱隔离控制。
实测结果符合预期:账号B即使拿到了文件URI,应用层仍然会检查文件归属标识,发现归属人不一致就拒绝解析。这个协同验证让我彻底理解了“文件访问控制管物理访问,业务逻辑管授权边界”两者之间的关系,前者关不掉也不能关,后者才是开发者真正需要花心思设计的地方。
5. 高频问题与排障实录
5.1 典型问题速查表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 登录时返回invalid token | 签名指纹与AGC后台不一致 | 对比本地签名证书SHA256与AGC配置 |
| 授权码换Token失败 | 授权码已过期或重复使用 | 重新拉起登录流程,每次用新Code |
| 云函数接口返回401 | 云函数开启“需要身份验证” | 后台改为允许匿名访问,或带API Key |
| 文件URI分享后对方打不开 | URI缺少file://前缀,或MIME类型错误 | 检查URI格式,使用标准MIME类型 |
| 私有目录被“其他应用”看到 | 把文件错误写到了公共目录 | 检查是否用了filesDir,而不是Download目录 |
| 动态申请权限没有弹窗 | 权限是system_grant级别,不需申请 | 非敏感权限声明后直接生效,无需request |
5.2 调试技巧:hdc、日志和断点三件套
调HarmonyOS 6的授权和文件相关代码,我的排障顺序永远是:先看DevEco日志面板里的hilog输出,再上hdc命令检查实际文件路径,最后才打断点。
日志里重点看权限相关错误码和接口返回的errCode。比如文件打不开时,错误码经常是201(权限校验失败)或者401(URI格式不对),根据错误码去查文档比瞎猜快得多。
hdc在检查私有文件时非常有用。比如我想确认“文件到底写入哪个路径”时,直接在终端跑:
bash复制hdc shell
find /data/app/el2/100/base/com.example.bill/files -name "*.json"
这条命令一下来,文件到底在不在、路径对不对,一目了然。Debug模式的断点则用于定位授权回调里的数据流,特别是确认authCode是否真的拿到了。
5.3 学习过程中最值得多花时间的三件事
第一,把官方Sample先跑通再改。HarmonyOS 6的账号登录和文件分享都有官方示例,任何自定义需求都建议以能跑通的Sample为起点。我跳过Sample直接写业务代码,结果在签名和权限上浪费了大量时间。
第二,交叉测试换用户再跑一遍。UnionID认证最怕只用一个测试账号跑通就觉得自己会了,实际上多账号切换时的缓存问题、Token过期问题都会暴露出来。多准备几个华为账号,至少验证两次完整登录退出流程。
第三,不要把ClientSecret放在前端。这是整个认证链路里最不能碰的一条红线。所有密钥相关操作都必须放服务端,哪怕前端多一个接口往返,也绝对不能省。
我在实际动手过程中最大的体会是:HarmonyOS 6把“应用私有文件隔离”和“用户统一认证”这两件事都内置到了系统能力里,开发者要做的不是从零发明安全方案,而是理解系统给定的边界,然后在这个边界之内设计出一个不过度授权、不裸奔密钥的业务闭环。记账应用虽然简单,但跑通了存储、认证、分享这三个环节之后,换到任何其他带用户体系的鸿蒙应用,思路都是相通的。最后再分享一个小技巧:调试授权码的时候,后端日志记得把关键返回值打出来,但千万别把access_token和unionid打进生产环境的日志,我吃过这个亏,后来所有敏感字段一律脱敏再输出。
