第6篇|上架前最后一道闸:HarmonyOS 相机应用的隐私协议、权限闸门与 AppGallery 审核闭环
首启弹窗使用 `PRIVACY_SUMMARY_TEXT`,用户点击查看完整内容时再展示 `PRIVACY_POLICY_TEXT` 或 `USER_AGREEMENT_TEXT`。{ icon: 'ic_profile_album', label: '隐私政策', desc: '了解数据使用与权限说明', action: 'privacy' },做移动端应用,尤其是相机、录像、定位、水印这类带敏
本文基于「晨迹相机」这个 HarmonyOS Stage 模型项目继续展开。前几篇我们已经打通了 `UIAbilityContext` 获取、`cameraPicker` 系统相机调用、拍摄结果保存、Preferences 极简 JSON 状态库、临时文件持久化和异常 Toast 兜底。
到了第 6 篇,重点不再是“功能能不能跑”,而是“这个相机 App 能不能经得起 AppGallery 的隐私与权限审核”。做移动端应用,尤其是相机、录像、定位、水印这类带敏感能力的应用,最容易犯的错误是:代码已经能拍照,构建也能通过,于是就觉得可以提交审核了。
实际提交时,审核会从另一个角度看你的应用:
- 用户第一次打开 App 时,是否已经明确知道你会申请相机、麦克风、定位?
- 用户不同意隐私政策时,是否仍能进入主界面?
- 是否存在“进入页面就申请权限”的过度打扰?
- App 内是否能再次查看用户协议和隐私政策?
- `module.json5` 里声明的权限、弹窗文案、隐私政策、真实代码行为是否一致?这一篇要做的,就是给「晨迹相机」补上上架前必须具备的一条闭环:首启隐私协议同意流程 + 敏感权限使用闸门 + 协议复查入口 + 权限说明一致性检查。
一、为什么相机 App 必须先做隐私闸门「晨迹相机」的核心功能是每日拍照/录像打卡,后续还会叠加水印、地点、天气、挑战、回忆等能力。
从产品视角看,它很简单:
打开 App
-> 选择拍照或录像
-> 调用系统相机
-> 保存照片/视频 URI
-> 补充心情和备注
-> 写入本地记录
但从审核视角看,它涉及四类敏感能力:
{
"name": "ohos.permission.CAMERA"
},
{
"name": "ohos.permission.MICROPHONE"
},
{
"name": "ohos.permission.APPROXIMATELY_LOCATION"
},
{
"name": "ohos.permission.LOCATION"
}
如果 App 一启动就展示主界面,用户还没有同意协议,就已经可以点击相机、录像、位置水印,这在上架前属于明显风险。所以我们需要把启动链路改成:
启动 App
-> 加载本地状态
-> 判断 privacyAgreed
-> 未同意:只显示隐私协议弹窗
-> 同意后:进入引导页或主界面
-> 用户主动点击拍照/录像
-> 再申请 CAMERA / MICROPHONE
-> 授权成功后才调用 cameraPicker
这里的核心原则是:隐私同意是产品入口闸门,系统权限申请是功能动作闸门,两者不能互相替代。
二、本篇改造涉及的文件本次改动不是只加一个弹窗,而是把“同意状态”贯穿到模型、存储、页面和文案:
entry/src/main/ets/common/models/AppModels.ets
entry/src/main/ets/common/services/AppDataStore.ets
entry/src/main/ets/common/constants/Legal.ets
entry/src/main/ets/features/app/CheckInCameraApp.ets
entry/src/main/module.json5
职责拆分如下:| 文件 | 责任 |
| --- | --- |
| `AppModels.ets` | 在 `AppState` 中增加 `privacyAgreed` |
| `AppDataStore.ets` | 负责读取、合并、保存协议同意状态 |
| `Legal.ets` | 集中维护隐私政策、用户协议、首启摘要 |
| `CheckInCameraApp.ets` | 控制弹窗展示、同意/拒绝逻辑、协议复查入口 |
| `module.json5` | 声明敏感权限与申请理由 |这样设计的好处是:页面不直接读写 Preferences,协议文本不散落在 UI 里,权限声明也能和隐私政策逐项对齐。
三、模型层:privacyAgreed 必须进入 AppState隐私协议是否已同意,不能只放在页面变量里。
页面变量只能控制当前渲染,App 重启后就丢失;而审核关注的是一个稳定的用户同意状态。因此我把它放进全局应用状态:
export interface AppState {
guideSeen: boolean;
privacyAgreed: boolean;
records: CheckInRecord[];
challenges: Challenge[];
watermarks: WatermarkConfig[];
ui: AppUiState;
}
默认值必须是 `false`:
export function createDefaultAppState(): AppState {
return {
guideSeen: false,
privacyAgreed: false,
records: createSeedRecords(),
challenges: createSeedChallenges(),
watermarks: createSeedWatermarks(),
ui: createDefaultUiState()
};
}
这里有一个很容易忽视的迁移问题:旧版本本地 JSON 里没有 `privacyAgreed` 字段。如果直接把旧 JSON 强转成 `AppState`,运行时可能出现:
privacyAgreed === undefined
所以合并状态时必须显式归一:
function mergeState(state: AppState): AppState {
return {
guideSeen: !!state.guideSeen,
privacyAgreed: !!state.privacyAgreed,
records,
challenges,
watermarks,
ui: mergeUiState(state.ui, fallback.ui)
};
}
`!!state.privacyAgreed` 的意义不只是类型转换,更是安全默认值:只要旧数据、损坏数据、缺失数据无法证明用户已同意,就按未同意处理。
四、存储层:让同意状态走统一服务本项目的状态存储使用 Preferences 保存一个极简 JSON:
STORE_NAME = check_in_camera_store
STATE_KEY = app_state
因此协议同意状态也必须走 `AppDataStore`,而不是在页面里单独开一个 Preferences。新增方法:
async setPrivacyAgreed(context: common.UIAbilityContext, agreed: boolean): Promise<AppState> {
this.state.privacyAgreed = agreed;
await this.save(context);
return copyState(this.state);
}
这段代码很短,但边界很清楚:- 页面表达“用户点击了同意”;
- service 修改内存态;
- service 写入 Preferences;
- service 返回一份深拷贝后的新状态;
- 页面再根据新状态决定展示引导页或主界面。不要让 UI 组件直接操作持久化 API。这个规则在小项目里看似啰嗦,但到了多页面、多入口、多状态时,它能避免非常多的同步问题。
五、文案层:Legal.ets 集中管理协议文本协议文本最怕散。
如果你把隐私政策摘要写在弹窗里,把完整政策写在另一个页面里,把权限说明又写在 README 或上架后台里,后面一旦权限变动,很容易漏改。
所以我单独新建:
entry/src/main/ets/common/constants/Legal.ets
里面集中导出:
export const PRIVACY_SUMMARY_TEXT: string = '...';
export const PRIVACY_POLICY_TEXT: string = '...';
export const USER_AGREEMENT_TEXT: string = '...';
首启弹窗使用 `PRIVACY_SUMMARY_TEXT`,用户点击查看完整内容时再展示 `PRIVACY_POLICY_TEXT` 或 `USER_AGREEMENT_TEXT`。更重要的是,隐私政策里的权限描述要和 manifest 对齐:
| manifest 权限 | 应用实际用途 | 隐私政策表达 |
| --- | --- | --- |
| CAMERA | 拍照/录像打卡 | 仅在用户点击拍照或录像时申请 |
| MICROPHONE | 录制视频声音 | 仅在录像模式下申请 |
| APPROXIMATELY_LOCATION | 城市或附近地点水印 | 仅在启用位置水印时使用 |
| LOCATION | 精确坐标水印 | 仅在需要精确位置水印时使用 |如果隐私政策说“不会收集位置信息”,但 `module.json5` 里声明了 `LOCATION`,这不是文案问题,而是审核风险。
六、启动层:bootstrap 里先判断协议状态在 `CheckInCameraApp` 里,启动流程由 `bootstrap()` 统一控制。
关键逻辑如下:
private async bootstrap(): Promise<void> {
this.pageState = 'loading';
try {
const state = await appDataStore.load(this.getContext());
this.appState = state;
this.applyUiState(state.ui);
this.syncCalendarDetail();
this.syncDraftsFromState();
if (!state.privacyAgreed) {
this.showPrivacy = true;
this.showGuide = false;
} else {
this.showPrivacy = false;
this.showGuide = !state.guideSeen;
}
this.pageState = 'content';
} catch (error) {
this.pageState = 'error';
this.pageErrorText = '本地数据初始化失败,请重试。';
}
}
这里有一个顺序细节:
隐私协议优先级 > 首次引导页优先级 > 主界面优先级
也就是说:- 未同意协议时,不展示 GuideOverlay;
- 未同意协议时,不让用户进入五栏主导航;
- 同意协议后,才根据 `guideSeen` 判断是否展示首次引导;
- 首次引导完成后,才进入完整主界面。这样做的体验也更自然:用户先知道应用会用什么数据,再了解产品怎么用。
七、交互层:PrivacySheet 必须有四个动作一个合格的首启协议弹窗,至少要有四个动作:
Button('查看用户协议')
Button('查看隐私政策')
Button('同意并继续使用')
Button('不同意并退出')
「同意并继续使用」写入持久化状态:
private async agreePrivacy(): Promise<void> {
try {
this.appState = await appDataStore.setPrivacyAgreed(this.getContext(), true);
} catch (error) {
// 持久化失败不阻止进入主界面,下次启动会再弹
}
this.showPrivacy = false;
this.showGuide = !this.appState.guideSeen;
}
「不同意并退出」结束当前 Ability:
private async rejectPrivacy(): Promise<void> {
try {
await this.getContext().terminateSelf();
} catch (error) {
this.showToast('请手动退出应用,未同意协议时无法使用。');
}
}
这里不要做成“不同意但进入首页”。只要用户还能进入首页,就仍然可能触发相机、录像、定位入口,隐私闸门就失效了。
八、叠层顺序:InfoSheet 要盖在 PrivacySheet 上面首启协议弹窗里有两个查看按钮:
查看用户协议
查看隐私政策
点击后会打开完整文本。如果 `Stack` 里的渲染顺序写错,就会出现“状态变了,但完整文本被协议弹窗挡住”的问题。正确顺序是:
if (this.showPrivacy) {
this.PrivacySheet()
}
if (this.showInfoSheet) {
this.InfoSheet()
}
ArkUI 的 `Stack` 后绘制内容会覆盖前面的内容,所以 `InfoSheet` 必须在 `PrivacySheet` 后面。这是一个典型的“编译不会报错,但用户看不到”的问题。做弹窗、底部面板、二次确认框时,都要特别注意层级顺序。
九、功能层:权限仍然要在用户动作之后申请同意隐私政策不等于授权系统权限。
它只是说明用户理解并接受应用的数据处理规则;真正调用相机、麦克风、定位前,仍然要走系统权限申请。
拍照/录像入口保留触发式申请:
private async requestPermissions(mode: CaptureMode): Promise<boolean> {
const result = mode === 'video'
? await cameraPermissionService.requestVideoPermission(this.getContext())
: await cameraPermissionService.requestPhotoPermission(this.getContext());
if (result.deniedPermissions.length > 0) {
this.cameraStatusText = mode === 'video' ? '录像需要相机和麦克风权限。' : '拍照需要相机权限。';
this.showToast(this.cameraStatusText);
return false;
}
return true;
}
完整链路应该是:
用户同意隐私政策
-> 用户进入主界面
-> 用户主动点击拍照/录像
-> App 申请 CAMERA / MICROPHONE
-> 用户授权
-> 调用 cameraPicker
位置权限也一样,不应该在 App 启动时申请,而应该在用户启用位置水印或相关功能时申请。
十、「我的」页:协议必须可复查首启弹一次不是终点。
上架前还要保证用户进入 App 后能再次查看协议。于是我在个人中心菜单中增加:
{ icon: 'ic_profile_album', label: '隐私政策', desc: '了解数据使用与权限说明', action: 'privacy' },
{ icon: 'ic_profile_album', label: '用户协议', desc: '查看应用使用条款', action: 'agreement' }
点击后复用 `openInfo()`:
if (item.action === 'privacy') {
this.openInfo('隐私政策', PRIVACY_POLICY_TEXT);
}
if (item.action === 'agreement') {
this.openInfo('用户协议', USER_AGREEMENT_TEXT);
}
这一步看似很小,但对审核材料非常关键:应用内必须有用户能再次查看协议的入口,而不是只在首启时短暂出现。
十一、和 AppGallery 审核相关的自查清单做完这次改造后,我建议每次上架前按这张表走一遍:
| 检查项 | 通过标准 |
| --- | --- |
| 首启协议 | 第一次打开 App 必须展示用户协议与隐私政策摘要 |
| 拒绝路径 | 用户点击不同意后不能进入主功能 |
| 同意持久化 | 同意后写入本地状态,重启不重复弹窗 |
| 主功能阻断 | 未同意前不能触发相机、麦克风、定位 |
| 权限触发 | 敏感权限必须在用户主动操作后申请 |
| 协议复查 | 「我的」或「设置」页能再次查看完整协议 |
| 文案一致 | 隐私政策、权限 reason、真实代码行为一致 |
| 本地存储说明 | 明确说明照片、心情、挑战、记录只保存在本地 |
| 删除说明 | 提供回收站、彻底删除、清空等用户可理解的删除路径 |
| 构建验证 | 修改后必须执行 `hvigorw.bat --no-daemon assembleHap` |尤其是相机类、录音类、定位类、文件类 App,不要把隐私协议当成最后补的文案。它应该和权限设计一起进入开发计划。
十二、这次改造的工程价值这篇改造完成后,项目多了几条很重要的工程约束:
1. 敏感能力不再裸露在首屏之后;
2. 用户同意状态进入持久化模型;
3. 协议文本集中维护,后续权限变化更容易同步;
4. 首启协议、首次引导、主界面的优先级清晰;
5. 系统权限申请仍保持“用户主动触发”;
6. App 内具备协议复查入口;
7. 代码行为和上架材料可以互相解释。这就是我理解的高质量 HarmonyOS 项目:不是每个页面都堆满功能,而是关键链路有边界、有证据、有兜底,出了问题能定位,上架审核也能说得清楚。
十三、本篇小结第 6 篇做的是一个上架前经常被低估的能力:隐私协议同意流程。
核心代码不复杂,真正重要的是顺序和边界:
AppState 保存 privacyAgreed
AppDataStore 统一持久化
Legal.ets 集中协议文本
bootstrap 先判断协议状态
PrivacySheet 提供同意/拒绝/查看完整协议
InfoSheet 处理完整协议阅读
ProfileTab 提供协议复查入口
权限申请仍放在用户主动动作之后
对于 HarmonyOS / AppGallery 项目来说,构建通过只能证明代码能编译;隐私流程完整,才能说明产品可以被用户和审核同时理解。下一篇可以继续往“发布前体验修复”走:比如签名冲突 `install sign info inconsistent`、DevEco 缓存包名、安装-启动-卸载烟测,以及这些问题如何进入项目错误日志,避免下一个项目重复踩坑。
更多推荐








所有评论(0)