【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 与 consoleStage 模型的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 → setTimeoutGameLoop在构造阶段尝试创建DisplaySynctry{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 errorasRecordstring,Object;return{name:String(candidate[name] ??UnknownError),message:String(candidate[message] ?? error),code: candidate[code] undefined?undefined:String(candidate[code]) }; }在严格 ArkTS 环境中可根据项目实际允许的联合类型调整签名。关键是统一提取允许记录的字段不直接序列化整个未知对象。八、隐私边界URI、IP、昵称和授权回调都要脱敏当前代码中有几类值得警惕的日志头像选择成功后记录完整 URIP2P 接收邀请时记录昵称和发送方 IP设备发现会序列化设备状态QQ 登录回调会序列化整个授权结果短信函数原型会记录请求和验证码这一问题会在下一篇安全文章单独展开。QQ 管理器已经明确打印Mock Mode说明当前是模拟模式而非真实 SDK 登录但日志习惯一旦保留到真实接入就可能输出 Token 或 OpenID。数据是否建议记录原值替代方式图片 URI否记录来源类型、是否成功、文件扩展名IP 地址调试期谨慎掩码或不可逆哈希生产默认不记录玩家昵称通常否玩家内部短 ID 或哈希授权回调否只记录 resultCode、provider、耗时Token/验证码绝不只记录是否存在与生命周期状态hilog格式中的 public/private 标记也要有意识使用。当前 Ability 错误使用%{public}s输出整个 JSON生产代码应先白名单化再决定字段是否可公开而不是把“已使用 hilog”误当成自动脱敏。九、给每次会话一个关联 ID当一次游戏涉及页面、引擎、音频、数据和 P2P多模块日志仅靠时间很难拼接。可以在进入一局时创建sessionIdinterface LogContext { sessionId:string;module:string; action:string; } logger.info(game_started, { sessionId,module:GameSession, action:start, mode, difficulty });关联 ID 不需要包含用户 ID、手机号或设备号。它只需在一次启动或一局游戏内唯一并在进入网络请求、结算和异常路径时向下传递。推荐的最小日志字段如下字段作用示例event稳定事件名game_init_failedlevel严重度warn/errormodule所属模块GameLoopsessionId串联一次会话随机短 IDerrorCode稳定分类LOOP-START-002durationMs操作耗时数值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 不兼容重试多少次都没用短暂网络断开或服务繁忙才适合重试。asyncfunctionretryT(task:() PromiseT,maxAttempts:number3):PromiseT {letlastError:ObjectnewError(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/fatalPreferences 读取使用明确默认值通常不打扰warnPreferences 保存保留脏状态、稍后重试关键资料可提示errorDisplaySync切换计时器不提示warn一次音效/振动静音或无触感继续设置页可显示不可用warn/metric头像选择保留旧头像权限或读取失败提示warnP2P 邀请标记发送失败、允许重试明确提示errorCanvas 单帧绘制跳过异常帧并计数高频时结束会话error限频游戏引擎的 render catch 当前会输出错误并继续。这样能防止一次绘制异常直接终止但如果每帧都报错日志会被淹没且用户只看到黑屏。建议增加连续失败计数偶发一次跳帧连续超过阈值后停止循环并进入可恢复错误界面。十三、建立一个轻量日志门面统一日志门面不是为了制造复杂框架而是把模块名、脱敏、错误归一化和环境策略集中起来classAppLogger{ info(event:string, fields: Recordstring, Object):void{ console.info(JSON.stringify({event, ...fields })); } error(event:string, fields: Recordstring, Object):void{ console.error(JSON.stringify({event, ...fields })); } }真实落地时还应开发构建允许更多诊断字段发布构建关闭详细网络与设备日志相同错误做采样和限频崩溃前尽可能刷出关键事件日志保留周期与上传行为写入隐私说明。十四、验证异常路径而不是只测成功路径 建议为以下场景建立故障注入让DisplaySync.create()抛错确认备用循环启动且只告警一次模拟 Preferences 读取损坏 JSON确认使用默认值且不覆盖原数据模拟路由 Promise reject确认 loading 恢复、Toast 可理解让音效播放失败确认游戏循环不受影响让 P2P 广播连续失败确认 UI 不会永远显示“发现中”传入包含敏感字段的授权回调确认日志只保留结果码连续触发 Canvas render error确认有限流和终止阈值。异常测试的验收标准不仅是“不崩溃”还包括状态不悬挂、用户不被误导、日志能关联、敏感字段不外泄。十五、总结 ✨“迷宫坦克派对”已经具备多层错误处理Ability 生命周期使用hilogGameLoop 对 DisplaySync 有真实 fallbackManager 和页面也普遍捕获 Promise/同步异常。但当前仍存在结构不统一、原始异常进入 Toast、完整 URI/IP/授权结果可能被记录以及部分 P2P、音频异常被静默吞掉等问题。异常治理的核心不是让每一行都包上try/catch而是明确失败后的产品行为能降级就记录一次并切换备用能力影响操作就告诉用户结果和下一步不可恢复就停止错误链路所有日志都使用稳定事件名、关联 ID、错误码与字段白名单。这样日志才能帮助定位问题Toast 才不会泄露开发细节fallback 也不再只是“假装没出错”。推荐标签OpenHarmonyHarmonyOSArkTShilog异常处理Promise日志治理降级策略