【OpenHarmony/HarmonyOS】真实项目中的异常治理:hilog、Promise、Toast 与降级策略
【OpenHarmony/HarmonyOS】真实项目中的异常治理:hilog、Promise、Toast 与降级策略
“捕获了异常”不等于“处理了异常”。一款 HarmonyOS 游戏同时面对窗口初始化失败、Preferences 读写失败、DisplaySync 不可用、音频文件缺失、路由失败、图片选择取消和局域网发送失败。它们不能全部弹 Toast,也不能全部写一句
console.error后继续。本文结合“迷宫坦克派对”的真实错误路径,建立从异常分类、日志结构、用户反馈到重试与降级的完整方法。🛡️
一、先把错误分成四类
异常治理的第一步不是选日志 API,而是判断失败后系统还能否履行承诺。
| 类型 | 项目中的例子 | 用户是否需要知道 | 推荐动作 |
|---|---|---|---|
| 致命启动错误 | 主页面loadContent失败 | 是 | 错误页/退出提示、完整错误日志 |
| 可降级能力 | DisplaySync 创建失败、振动不支持 | 通常不需要 | 切换备用路径,记录一次告警 |
| 可重试业务错误 | 云端提交、P2P 邀请发送失败 | 视操作而定 | 有界重试、明确失败状态 |
| 用户输入/权限问题 | 未同意协议、相册授权失败 | 是 | 可理解的 Toast 或页面提示 |
同一个catch中最关键的问题是:“接下来还能做什么?”如果答案是可以切换到setTimeout,这叫降级;如果只是把错误注释掉而仍对 UI 声称发送成功,那叫静默失败。
二、项目现在同时使用 hilog 与 console
Stage 模型的EntryAbility使用hilog记录生命周期:
constDOMAIN =0x0000; onCreate(want: Want, launchParam: AbilityConstant.LaunchParam):void{try{this.context.getApplicationContext() .setColorMode(ConfigurationConstant.ColorMode.COLOR_MODE_NOT_SET); }catch(err) { hilog.error( DOMAIN,'testTag','Failed to set colorMode. Cause: %{public}s', JSON.stringify(err) ); } hilog.info(DOMAIN,'testTag','%{public}s','Ability onCreate'); }而引擎、Manager 与页面多数使用console.info/warn/error。例如GameEngine、GameLoop、DataManager都带有模块前缀。这在开发阶段能工作,但长期会出现三个问题:
testTag无法区分窗口、启动和数据初始化;console文本格式各异,不便按错误码和会话聚合;- 多处直接
JSON.stringify(error),既可能只得到{},也可能输出不该公开的数据。
治理并不要求一次性替换所有console。可以先统一字段和模块名,再逐步把系统生命周期、关键业务失败迁移到统一日志门面。
三、日志、用户提示和遥测是三条不同通道
flowchart TD A["捕获错误"] --> B{"是否影响当前操作?"} B --"否,可降级"--> C["记录 warn + 启用 fallback"] B --"是,可恢复"--> D["记录 error + 用户友好提示 + 重试入口"] B --"是,不可恢复"--> E["终止当前流程 + 错误页/返回"] C --> F["结构化诊断日志"] D --> F E --> F F --> G["脱敏遥测与聚合"]- 开发日志回答“哪里、什么时候、因为什么失败”;
- 用户提示回答“刚才的操作有没有成功、我下一步能做什么”;
- 遥测回答“这个错误影响多少设备、哪个版本开始增加”。
不能把开发异常对象直接交给用户,也不能用一句“操作失败”代替诊断上下文。
四、一个做得较好的降级:DisplaySync → setTimeout
GameLoop在构造阶段尝试创建DisplaySync:
try{this.displaySync = displaySync.create();this.useDisplaySync =true; }catch(e) { console.warn('[GameLoop] DisplaySync not supported, falling back to setTimeout');this.useDisplaySync =false; }启动DisplaySync失败时也会进入loopFallback()。这里具备完整降级的三个要素:
| 要素 | 当前实现 |
|---|---|
| 能力探测 | 尝试displaySync.create() |
| 失败可观测 | 记录 warning/error |
| 备用实现 | 使用setTimeout驱动循环 |
用户仍然可以进入游戏,因此没有必要连续弹 Toast。更进一步,可以只在一次会话中记录一次能力降级,并附上设备版本、目标 FPS 和 fallback 类型;不要每帧重复输出相同告警。
五、吞掉异常并不总是错,但必须知道代价
项目中有多处空catch或注释掉的日志:
try{this.displaySync.stop(); }catch(e) {// ignore}停止一个本来就不可用的帧同步对象,忽略异常通常不会影响用户,属于“清理阶段尽力而为”。但 P2P 广播发送失败与音频播放失败也存在静默路径:
try{awaitthis.udpSocket.send(packet); }catch(e) {// Ignore broadcast errors}soundPool.play(soundId, options).catch((e:Error)=>{//Play erroriscurrently suppressed });两者影响不同:音效失败不应阻止战斗,适合低频告警与静音降级;发现广播持续失败会让附近玩家永远互相看不见,如果 UI 仍显示“正在发现”,就形成误导。
可以用以下规则判断是否允许静默:
- 失败不会改变主要业务结果;
- 已有可靠 fallback;
- 不需要用户立刻修复;
- 仍有聚合指标能发现高频失败;
- catch 不会掩盖编程错误或数据损坏。
六、Toast 不能展示原始异常对象 ⚠️
设置页在语言切换异常时有如下路径:
}catch(e) { console.error("Failed to set language: "+JSON.stringify(e) );try{ promptAction.showToast({ message:'Error: '+JSON.stringify(e) }); }catch(inner) {} }这会把开发细节暴露给用户。错误对象可能显示为{},也可能包含系统 API 名、路径或内部状态;文本长度还可能超出 Toast 的可读范围。
更合理的是稳定的用户文案加内部错误码:
const errorId ='SETTINGS-LANG-001'; logger.error('language_switch_failed', { errorId, targetLanguage: lang, cause: normalizeError(e) }); promptAction.showToast({ message:'语言切换失败,请稍后重试'});需要客服协查时,可以在详情页显示短错误编号,而不是把完整异常塞进短暂 Toast。
七、Error 类型归一化,避免日志里全是{}
JavaScript/ArkTS 的catch值不一定是Error,也可能是字符串、业务错误对象甚至null。而标准Error.message、stack常常不是可枚举字段,JSON.stringify(new Error('x'))可能只得到{}。
可以集中归一化:
interfaceNormalizedError {name:string;message:string; code?:string; }functionnormalizeError(error:Object):NormalizedError{constcandidate = errorasRecord<string,Object>;return{name:String(candidate['name'] ??'UnknownError'),message:String(candidate['message'] ?? error),code: candidate['code'] ===undefined?undefined:String(candidate['code']) }; }在严格 ArkTS 环境中,可根据项目实际允许的联合类型调整签名。关键是统一提取允许记录的字段,不直接序列化整个未知对象。
八、隐私边界:URI、IP、昵称和授权回调都要脱敏
当前代码中有几类值得警惕的日志:
- 头像选择成功后记录完整 URI;
- P2P 接收邀请时记录昵称和发送方 IP;
- 设备发现会序列化设备状态;
- QQ 登录回调会序列化整个授权结果;
- 短信函数原型会记录请求和验证码,这一问题会在下一篇安全文章单独展开。
QQ 管理器已经明确打印Mock Mode,说明当前是模拟模式而非真实 SDK 登录,但日志习惯一旦保留到真实接入,就可能输出 Token 或 OpenID。
| 数据 | 是否建议记录原值 | 替代方式 |
|---|---|---|
| 图片 URI | 否 | 记录来源类型、是否成功、文件扩展名 |
| IP 地址 | 调试期谨慎 | 掩码或不可逆哈希,生产默认不记录 |
| 玩家昵称 | 通常否 | 玩家内部短 ID 或哈希 |
| 授权回调 | 否 | 只记录 resultCode、provider、耗时 |
| Token/验证码 | 绝不 | 只记录是否存在与生命周期状态 |
hilog格式中的 public/private 标记也要有意识使用。当前 Ability 错误使用%{public}s输出整个 JSON;生产代码应先白名单化,再决定字段是否可公开,而不是把“已使用 hilog”误当成自动脱敏。
九、给每次会话一个关联 ID
当一次游戏涉及页面、引擎、音频、数据和 P2P,多模块日志仅靠时间很难拼接。可以在进入一局时创建sessionId:
interface LogContext { sessionId:string;module:string; action:string; } logger.info('game_started', { sessionId,module:'GameSession', action:'start', mode, difficulty });关联 ID 不需要包含用户 ID、手机号或设备号。它只需在一次启动或一局游戏内唯一,并在进入网络请求、结算和异常路径时向下传递。
推荐的最小日志字段如下:
| 字段 | 作用 | 示例 |
|---|---|---|
| event | 稳定事件名 | game_init_failed |
| level | 严重度 | warn/error |
| module | 所属模块 | GameLoop |
| sessionId | 串联一次会话 | 随机短 ID |
| errorCode | 稳定分类 | LOOP-START-002 |
| durationMs | 操作耗时 | 数值 |
| fallback | 是否降级 | setTimeout |
十、Promise 的错误必须在职责边界收口
项目路由常使用:
router.replaceUrl({url:'pages/Index',params: {isLoggedIn:true} }).catch((err:Error) =>{console.error(`[StartPage] Failed to replace url. `+`Code:${err.name}, Message:${err.message}`); });它避免了未处理的 Promise rejection,但仍缺少用户层结果:路由失败后页面停在哪里?按钮是否恢复可点击?是否允许重试?
一个完整的异步操作通常需要:
this.isLoading =true;try{await router.pushUrl({ url:'pages/SettingsPage'}); }catch(error) { logger.error('open_settings_failed', { cause: normalizeError(erroras Object) }); promptAction.showToast({ message: '暂时无法打开设置' }); }finally{this.isLoading =false; }finally防止 loading 永久不消失。只有调用方知道按钮、页面与用户预期,所以 Promise 错误应在最靠近业务动作的边界收口;底层 Manager 可以抛出带错误码的异常,但不应自行弹 UI。
十一、重试必须有上限、退避和幂等性
并非所有错误都适合立即重试:参数错误、权限拒绝、Schema 不兼容,重试多少次都没用;短暂网络断开或服务繁忙才适合重试。
asyncfunctionretry<T>(task:() =>Promise<T>,maxAttempts:number=3):Promise<T> {letlastError:Object=newError('unknown');for(letattempt =1; attempt <= maxAttempts; attempt++) {try{returnawaittask(); }catch(error) { lastError = errorasObject;if(attempt < maxAttempts) {awaitdelay(200*Math.pow(2, attempt -1)); } } }throwlastError; }提交分数、发放晶石、创建房间一类写操作还要带幂等键,否则重试可能重复入账。P2P 状态广播则通常“新帧覆盖旧帧”,没有必要重发每一个旧包。
十二、不同模块的推荐策略
| 模块 | 失败策略 | 用户反馈 | 日志级别 |
|---|---|---|---|
EntryAbility.loadContent | 终止启动流程 | 错误页或系统级提示 | error/fatal |
| Preferences 读取 | 使用明确默认值 | 通常不打扰 | warn |
| Preferences 保存 | 保留脏状态、稍后重试 | 关键资料可提示 | error |
| DisplaySync | 切换计时器 | 不提示 | warn,一次 |
| 音效/振动 | 静音或无触感继续 | 设置页可显示不可用 | warn/metric |
| 头像选择 | 保留旧头像 | 权限或读取失败提示 | warn |
| P2P 邀请 | 标记发送失败、允许重试 | 明确提示 | error |
| Canvas 单帧绘制 | 跳过异常帧并计数 | 高频时结束会话 | error,限频 |
游戏引擎的 render catch 当前会输出错误并继续。这样能防止一次绘制异常直接终止,但如果每帧都报错,日志会被淹没且用户只看到黑屏。建议增加连续失败计数:偶发一次跳帧,连续超过阈值后停止循环并进入可恢复错误界面。
十三、建立一个轻量日志门面
统一日志门面不是为了制造复杂框架,而是把模块名、脱敏、错误归一化和环境策略集中起来:
classAppLogger{ info(event:string, fields: Record<string, Object>):void{ console.info(JSON.stringify({event, ...fields })); } error(event:string, fields: Record<string, Object>):void{ console.error(JSON.stringify({event, ...fields })); } }真实落地时还应:开发构建允许更多诊断字段,发布构建关闭详细网络与设备日志;相同错误做采样和限频;崩溃前尽可能刷出关键事件;日志保留周期与上传行为写入隐私说明。
十四、验证异常路径,而不是只测成功路径 🧪
建议为以下场景建立故障注入:
- 让
DisplaySync.create()抛错,确认备用循环启动且只告警一次; - 模拟 Preferences 读取损坏 JSON,确认使用默认值且不覆盖原数据;
- 模拟路由 Promise reject,确认 loading 恢复、Toast 可理解;
- 让音效播放失败,确认游戏循环不受影响;
- 让 P2P 广播连续失败,确认 UI 不会永远显示“发现中”;
- 传入包含敏感字段的授权回调,确认日志只保留结果码;
- 连续触发 Canvas render error,确认有限流和终止阈值。
异常测试的验收标准不仅是“不崩溃”,还包括状态不悬挂、用户不被误导、日志能关联、敏感字段不外泄。
十五、总结 ✨
“迷宫坦克派对”已经具备多层错误处理:Ability 生命周期使用hilog,GameLoop 对 DisplaySync 有真实 fallback,Manager 和页面也普遍捕获 Promise/同步异常。但当前仍存在结构不统一、原始异常进入 Toast、完整 URI/IP/授权结果可能被记录,以及部分 P2P、音频异常被静默吞掉等问题。
异常治理的核心不是让每一行都包上try/catch,而是明确失败后的产品行为:能降级就记录一次并切换备用能力;影响操作就告诉用户结果和下一步;不可恢复就停止错误链路;所有日志都使用稳定事件名、关联 ID、错误码与字段白名单。这样,日志才能帮助定位问题,Toast 才不会泄露开发细节,fallback 也不再只是“假装没出错”。🔧
推荐标签:OpenHarmonyHarmonyOSArkTShilog异常处理Promise日志治理降级策略
