Java 异常、日志与 Android 崩溃定位

订单页偶发显示空白:仓库层读取响应体时抛出 IOException,ViewModel 只记录了 e.getMessage(),主线程随后收到一个没有业务含义的异常。开发者既不知道是哪一次请求失败,也不知道用户看到了什么结果。问题不在于"有没有 catch",而在于异常跨层时有没有被翻译、记录和交付。

一句话结论: Java 异常应在最了解失败语义的层被转换为可诊断的领域错误,UI 只消费可恢复状态,而日志、崩溃上报和 StrictMode 分别承担观察、终止边界通知和开发期约束。

本文以 Java 17 语言语义、Android 14 附近的 AOSP 概念路径为边界;android.util.LogRuntimeInit 与 StrictMode 的内部细节会随 Android 版本变化,第三方崩溃 SDK 的实现更不是 Android 平台契约。

先看失败如何抵达 UI

受检异常(如 IOException)要求调用方声明或处理,适合表示调用者可预期且能采取动作的 I/O 失败;运行时异常(如 NullPointerExceptionIllegalArgumentException)通常表示编程错误或违反前置条件。两者都可以携带 cause 和 stack trace,但不该原样穿过 UI 边界。
#mermaid-svg-ckCthEBcQFJuSZkg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-ckCthEBcQFJuSZkg .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-ckCthEBcQFJuSZkg .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-ckCthEBcQFJuSZkg .error-icon{fill:#552222;}#mermaid-svg-ckCthEBcQFJuSZkg .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-ckCthEBcQFJuSZkg .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-ckCthEBcQFJuSZkg .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-ckCthEBcQFJuSZkg .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-ckCthEBcQFJuSZkg .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-ckCthEBcQFJuSZkg .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-ckCthEBcQFJuSZkg .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-ckCthEBcQFJuSZkg .marker{fill:#333333;stroke:#333333;}#mermaid-svg-ckCthEBcQFJuSZkg .marker.cross{stroke:#333333;}#mermaid-svg-ckCthEBcQFJuSZkg svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-ckCthEBcQFJuSZkg p{margin:0;}#mermaid-svg-ckCthEBcQFJuSZkg .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-ckCthEBcQFJuSZkg .cluster-label text{fill:#333;}#mermaid-svg-ckCthEBcQFJuSZkg .cluster-label span{color:#333;}#mermaid-svg-ckCthEBcQFJuSZkg .cluster-label span p{background-color:transparent;}#mermaid-svg-ckCthEBcQFJuSZkg .label text,#mermaid-svg-ckCthEBcQFJuSZkg span{fill:#333;color:#333;}#mermaid-svg-ckCthEBcQFJuSZkg .node rect,#mermaid-svg-ckCthEBcQFJuSZkg .node circle,#mermaid-svg-ckCthEBcQFJuSZkg .node ellipse,#mermaid-svg-ckCthEBcQFJuSZkg .node polygon,#mermaid-svg-ckCthEBcQFJuSZkg .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-ckCthEBcQFJuSZkg .rough-node .label text,#mermaid-svg-ckCthEBcQFJuSZkg .node .label text,#mermaid-svg-ckCthEBcQFJuSZkg .image-shape .label,#mermaid-svg-ckCthEBcQFJuSZkg .icon-shape .label{text-anchor:middle;}#mermaid-svg-ckCthEBcQFJuSZkg .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-ckCthEBcQFJuSZkg .rough-node .label,#mermaid-svg-ckCthEBcQFJuSZkg .node .label,#mermaid-svg-ckCthEBcQFJuSZkg .image-shape .label,#mermaid-svg-ckCthEBcQFJuSZkg .icon-shape .label{text-align:center;}#mermaid-svg-ckCthEBcQFJuSZkg .node.clickable{cursor:pointer;}#mermaid-svg-ckCthEBcQFJuSZkg .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-ckCthEBcQFJuSZkg .arrowheadPath{fill:#333333;}#mermaid-svg-ckCthEBcQFJuSZkg .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-ckCthEBcQFJuSZkg .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-ckCthEBcQFJuSZkg .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ckCthEBcQFJuSZkg .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-ckCthEBcQFJuSZkg .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ckCthEBcQFJuSZkg .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-ckCthEBcQFJuSZkg .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-ckCthEBcQFJuSZkg .cluster text{fill:#333;}#mermaid-svg-ckCthEBcQFJuSZkg .cluster span{color:#333;}#mermaid-svg-ckCthEBcQFJuSZkg div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-ckCthEBcQFJuSZkg .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-ckCthEBcQFJuSZkg rect.text{fill:none;stroke-width:0;}#mermaid-svg-ckCthEBcQFJuSZkg .icon-shape,#mermaid-svg-ckCthEBcQFJuSZkg .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-ckCthEBcQFJuSZkg .icon-shape p,#mermaid-svg-ckCthEBcQFJuSZkg .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-ckCthEBcQFJuSZkg .icon-shape .label rect,#mermaid-svg-ckCthEBcQFJuSZkg .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-ckCthEBcQFJuSZkg .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-ckCthEBcQFJuSZkg .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-ckCthEBcQFJuSZkg :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 抛出 IOException
返回 DomainError
通知失败结果
渲染
记录关联日志
Repository:读取网络或磁盘
错误映射器:识别失败语义
UseCase:保留业务上下文
ViewModel:生成 UI State
Screen:显示重试或空态
Log:输出诊断字段

谁创建:Repository 创建原始失败,或接住 Java/框架抛出的异常;谁持有:领域层持有 DomainError 的稳定语义,日志系统持有一次输出;何时触发:I/O 或解析边界失败时;结果交给谁:ViewModel 接收已翻译的结果,Screen 接收 UI 状态。这样 UI 不需要知道 SocketTimeoutException 的每个子类,也不会为某个 HTTP 客户端绑定。

异常不是同一种信号

受检异常与运行时异常

  • IOException 是典型受检异常。资源读取者必须 catch,或在方法签名中 throws;它适合在数据边界被转换为 NetworkStorage 之类的错误。
  • RuntimeException 不强制声明。参数校验失败可以用 IllegalArgumentException 明确暴露;NullPointerException 多半应通过修正不变量消失,而非被 UI 当作"网络失败"。
  • 不要用 catch (Throwable) 做业务兜底。它会拦住 Error,例如 OutOfMemoryError,也会干扰取消、调试和进程终止语义。

try-with-resources 是所有权语法

try-with-resources 在离开作用域时按反向顺序关闭实现 AutoCloseable 的资源。若主体和关闭动作都失败,关闭失败通常作为 suppressed exception 附在主体异常上;排障时应查看 getSuppressed(),而不只看一行消息。它解决资源关闭,不自动解决重试、线程调度或错误展示。

来源锚点与职责

下表把公开入口和 AOSP 概念路径分开。类名是定位线索,不能当作稳定的应用 API。

层与来源锚点 谁创建 谁持有 何时触发 错误信息交给谁
android.util.Log,入口如 Log.e,概念执行边界为 native log buffer 应用代码或框架调用创建日志事件 logd 守护进程维护设备日志缓冲区;Logcat 与采集工具读取并筛选输出 显式调用 Log.dLog.wLog.e 开发者、测试系统或日志采集管道读取
Java Thread.UncaughtExceptionHandler,Android 概念入口为 RuntimeInit 的未捕获处理 某线程上未被处理的 throwable 到达线程根部 Thread 的 handler 链与运行时边界持有当前失败 线程退出前的未捕获异常 默认处理器记录致命信息并走进程终止路径
崩溃收集 SDK,示例为 Crashlytics 的 handler 包装 SDK 初始化时注册自己的收集器 SDK 进程内缓存和它的上传队列持有报告 未捕获崩溃或 SDK 显式记录非致命错误 SDK 服务端与团队控制台接收报告
StrictMode,入口为 StrictMode.setThreadPolicysetVmPolicy Application 或测试初始化代码创建策略 线程策略与 VM 策略由运行时执行 磁盘、网络、泄漏等违规被检测 Logcat、监听器或开发期 penalty;可选 penalty death 终止

概念来源事实: Log 是 Android 平台日志 API;未捕获异常会抵达线程的 UncaughtExceptionHandler;StrictMode 是策略违规检测机制。AOSP 的具体默认 handler、日志格式和终止细节会版本化。

SDK 特有行为: 某个崩溃 SDK 是否安装默认 handler、何时落盘、何时上传、是否采样和如何脱敏,取决于该 SDK 版本、配置与网络条件。应用不能把"已经调用 record"误解为"用户不会崩溃",也不能把它当作平台恢复机制。

Log:让一次失败可被检索

一条有用的错误日志至少应含:稳定 tag、事件名、correlation id、业务操作、非敏感目标、线程或前后台状态、错误类别、cause,以及 throwable 本身。Log.e(TAG, message, throwable) 会让 stack trace 随记录输出;只有 getMessage() 往往丢掉调用链和 cause。

java 复制代码
String event = "profile_load_failed";
Log.e("ProfileRepository",
        "event=" + event
                + " cid=" + correlationId
                + " operation=load_profile"
                + " endpoint=/profile"
                + " error=" + error.code(),
        error.cause());

不要写入 access token、Cookie、完整手机号、地址、原始响应体或用户输入。相关 id 应能关联一次操作而不成为身份标识;真正的脱敏策略、采样和保留期限属于生产日志系统的职责。

报告和恢复是两条不同的路径

崩溃收集的目标是把致命现场交给工程团队;恢复的目标是把预期失败转换成用户还能理解和继续操作的状态。未捕获异常到达线程边界时,应用正常逻辑已经失去可信执行上下文,UncaughtExceptionHandler 不应被写成"重启 Activity 并继续运行"的恢复通道。
#mermaid-svg-MbTJ9MDKDviRZ42f{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MbTJ9MDKDviRZ42f .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MbTJ9MDKDviRZ42f .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MbTJ9MDKDviRZ42f .error-icon{fill:#552222;}#mermaid-svg-MbTJ9MDKDviRZ42f .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MbTJ9MDKDviRZ42f .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MbTJ9MDKDviRZ42f .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MbTJ9MDKDviRZ42f .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MbTJ9MDKDviRZ42f .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MbTJ9MDKDviRZ42f .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MbTJ9MDKDviRZ42f .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MbTJ9MDKDviRZ42f .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MbTJ9MDKDviRZ42f .marker.cross{stroke:#333333;}#mermaid-svg-MbTJ9MDKDviRZ42f svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MbTJ9MDKDviRZ42f p{margin:0;}#mermaid-svg-MbTJ9MDKDviRZ42f .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-MbTJ9MDKDviRZ42f .cluster-label text{fill:#333;}#mermaid-svg-MbTJ9MDKDviRZ42f .cluster-label span{color:#333;}#mermaid-svg-MbTJ9MDKDviRZ42f .cluster-label span p{background-color:transparent;}#mermaid-svg-MbTJ9MDKDviRZ42f .label text,#mermaid-svg-MbTJ9MDKDviRZ42f span{fill:#333;color:#333;}#mermaid-svg-MbTJ9MDKDviRZ42f .node rect,#mermaid-svg-MbTJ9MDKDviRZ42f .node circle,#mermaid-svg-MbTJ9MDKDviRZ42f .node ellipse,#mermaid-svg-MbTJ9MDKDviRZ42f .node polygon,#mermaid-svg-MbTJ9MDKDviRZ42f .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-MbTJ9MDKDviRZ42f .rough-node .label text,#mermaid-svg-MbTJ9MDKDviRZ42f .node .label text,#mermaid-svg-MbTJ9MDKDviRZ42f .image-shape .label,#mermaid-svg-MbTJ9MDKDviRZ42f .icon-shape .label{text-anchor:middle;}#mermaid-svg-MbTJ9MDKDviRZ42f .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-MbTJ9MDKDviRZ42f .rough-node .label,#mermaid-svg-MbTJ9MDKDviRZ42f .node .label,#mermaid-svg-MbTJ9MDKDviRZ42f .image-shape .label,#mermaid-svg-MbTJ9MDKDviRZ42f .icon-shape .label{text-align:center;}#mermaid-svg-MbTJ9MDKDviRZ42f .node.clickable{cursor:pointer;}#mermaid-svg-MbTJ9MDKDviRZ42f .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-MbTJ9MDKDviRZ42f .arrowheadPath{fill:#333333;}#mermaid-svg-MbTJ9MDKDviRZ42f .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-MbTJ9MDKDviRZ42f .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-MbTJ9MDKDviRZ42f .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MbTJ9MDKDviRZ42f .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-MbTJ9MDKDviRZ42f .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MbTJ9MDKDviRZ42f .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-MbTJ9MDKDviRZ42f .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-MbTJ9MDKDviRZ42f .cluster text{fill:#333;}#mermaid-svg-MbTJ9MDKDviRZ42f .cluster span{color:#333;}#mermaid-svg-MbTJ9MDKDviRZ42f div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-MbTJ9MDKDviRZ42f .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-MbTJ9MDKDviRZ42f rect.text{fill:none;stroke-width:0;}#mermaid-svg-MbTJ9MDKDviRZ42f .icon-shape,#mermaid-svg-MbTJ9MDKDviRZ42f .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-MbTJ9MDKDviRZ42f .icon-shape p,#mermaid-svg-MbTJ9MDKDviRZ42f .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-MbTJ9MDKDviRZ42f .icon-shape .label rect,#mermaid-svg-MbTJ9MDKDviRZ42f .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-MbTJ9MDKDviRZ42f .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-MbTJ9MDKDviRZ42f .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-MbTJ9MDKDviRZ42f :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 未捕获异常:到达线程根部
UncaughtExceptionHandler:致命边界
Android Runtime:记录并终止进程
Crash Collector:尽力保存报告
SDK Queue:等待合适上传时机
Crash Service:聚合与告警
下次启动:重新建立状态

谁创建:代码缺陷或未翻译异常创建致命 throwable;谁持有:当前线程和 handler 链短暂持有它;何时触发:异常逃出 run 或线程入口;结果交给谁:平台终止路径得到它,收集 SDK 只能尽力旁路记录。恢复应更早发生在 Repository、UseCase 或 ViewModel 的显式结果分支。

网络、领域错误与 UI 状态

同一个底层失败在不同业务里含义不同。HTTP 401 对账户页可能是"要求登录",对后台静默刷新可能是"刷新令牌失效";同为 IOException 也可能是离线、超时或证书问题。映射层应保留 cause 供诊断,但向上暴露稳定类别和可执行动作。
#mermaid-svg-6rWbpE0zsIK9T1AP{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-6rWbpE0zsIK9T1AP .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-6rWbpE0zsIK9T1AP .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-6rWbpE0zsIK9T1AP .error-icon{fill:#552222;}#mermaid-svg-6rWbpE0zsIK9T1AP .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-6rWbpE0zsIK9T1AP .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-6rWbpE0zsIK9T1AP .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-6rWbpE0zsIK9T1AP .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-6rWbpE0zsIK9T1AP .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-6rWbpE0zsIK9T1AP .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-6rWbpE0zsIK9T1AP .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-6rWbpE0zsIK9T1AP .marker{fill:#333333;stroke:#333333;}#mermaid-svg-6rWbpE0zsIK9T1AP .marker.cross{stroke:#333333;}#mermaid-svg-6rWbpE0zsIK9T1AP svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-6rWbpE0zsIK9T1AP p{margin:0;}#mermaid-svg-6rWbpE0zsIK9T1AP .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-6rWbpE0zsIK9T1AP .cluster-label text{fill:#333;}#mermaid-svg-6rWbpE0zsIK9T1AP .cluster-label span{color:#333;}#mermaid-svg-6rWbpE0zsIK9T1AP .cluster-label span p{background-color:transparent;}#mermaid-svg-6rWbpE0zsIK9T1AP .label text,#mermaid-svg-6rWbpE0zsIK9T1AP span{fill:#333;color:#333;}#mermaid-svg-6rWbpE0zsIK9T1AP .node rect,#mermaid-svg-6rWbpE0zsIK9T1AP .node circle,#mermaid-svg-6rWbpE0zsIK9T1AP .node ellipse,#mermaid-svg-6rWbpE0zsIK9T1AP .node polygon,#mermaid-svg-6rWbpE0zsIK9T1AP .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-6rWbpE0zsIK9T1AP .rough-node .label text,#mermaid-svg-6rWbpE0zsIK9T1AP .node .label text,#mermaid-svg-6rWbpE0zsIK9T1AP .image-shape .label,#mermaid-svg-6rWbpE0zsIK9T1AP .icon-shape .label{text-anchor:middle;}#mermaid-svg-6rWbpE0zsIK9T1AP .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-6rWbpE0zsIK9T1AP .rough-node .label,#mermaid-svg-6rWbpE0zsIK9T1AP .node .label,#mermaid-svg-6rWbpE0zsIK9T1AP .image-shape .label,#mermaid-svg-6rWbpE0zsIK9T1AP .icon-shape .label{text-align:center;}#mermaid-svg-6rWbpE0zsIK9T1AP .node.clickable{cursor:pointer;}#mermaid-svg-6rWbpE0zsIK9T1AP .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-6rWbpE0zsIK9T1AP .arrowheadPath{fill:#333333;}#mermaid-svg-6rWbpE0zsIK9T1AP .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-6rWbpE0zsIK9T1AP .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-6rWbpE0zsIK9T1AP .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6rWbpE0zsIK9T1AP .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-6rWbpE0zsIK9T1AP .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6rWbpE0zsIK9T1AP .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-6rWbpE0zsIK9T1AP .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-6rWbpE0zsIK9T1AP .cluster text{fill:#333;}#mermaid-svg-6rWbpE0zsIK9T1AP .cluster span{color:#333;}#mermaid-svg-6rWbpE0zsIK9T1AP div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-6rWbpE0zsIK9T1AP .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-6rWbpE0zsIK9T1AP rect.text{fill:none;stroke-width:0;}#mermaid-svg-6rWbpE0zsIK9T1AP .icon-shape,#mermaid-svg-6rWbpE0zsIK9T1AP .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-6rWbpE0zsIK9T1AP .icon-shape p,#mermaid-svg-6rWbpE0zsIK9T1AP .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-6rWbpE0zsIK9T1AP .icon-shape .label rect,#mermaid-svg-6rWbpE0zsIK9T1AP .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-6rWbpE0zsIK9T1AP .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-6rWbpE0zsIK9T1AP .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-6rWbpE0zsIK9T1AP :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} Transport:HTTP 或文件失败
Classifier:分类状态与原因
DomainError:网络不可用
DomainError:需要认证
DomainError:数据不合法
UI State:显示重试
UI State:跳转登录
UI State:显示可理解提示
Log:关联诊断上下文

这里谁创建:Classifier 根据 transport 结果创建领域错误;谁持有:UseCase 或 ViewModel 持有短生命周期结果;何时触发:边界返回失败或解析不变量不满足;结果交给谁:UI 收到 RetryLogin 或提示状态,Log 收到完整但脱敏的诊断上下文。

Java-first 简化模型

下面的代码是可改造的教学样例。它展示"保留 cause、翻译语义、带 correlation id 记录"的最小闭环;省略了重试策略、指数退避、隐私脱敏的具体实现、线程切换、HTTP 客户端配置,以及任何 SDK 上报传输。

java 复制代码
import android.util.Log;
import java.io.BufferedReader;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;

final class DomainError extends Exception {
    enum Code { NETWORK, STORAGE, INVALID_DATA }

    private final Code code;
    private final String correlationId;

    DomainError(Code code, String correlationId, Throwable cause) {
        super(code.name(), cause);
        this.code = code;
        this.correlationId = correlationId;
    }

    Code code() { return code; }
    String correlationId() { return correlationId; }
}

final class ProfileRepository {
    String load(Path cache, String correlationId) throws DomainError {
        try (BufferedReader reader = Files.newBufferedReader(cache)) {
            String value = reader.readLine();
            if (value == null || value.isBlank()) {
                throw new IllegalStateException("empty profile cache");
            }
            return value;
        } catch (IOException exception) {
            DomainError error = new DomainError(
                    DomainError.Code.STORAGE, correlationId, exception);
            Log.e("ProfileRepository",
                    "event=profile_load_failed cid=" + correlationId
                            + " error=" + error.code(), exception);
            throw error;
        }
    }
}

在 Android 实际工程中,java.nio.file 是否可用和路径如何取得取决于最低 API、desugaring 与存储方案;把 Path 换成 ContentResolver、Okio 或你已有的客户端即可。关键不变:资源由拥有者关闭,底层异常只在边界翻译一次,UI 不直接理解 I/O 类型。

StrictMode:把慢和泄漏提早暴露

StrictMode 可在开发和测试阶段发现主线程磁盘或网络访问、可关闭资源泄漏、泄漏的 SQLite 对象等策略违规。它不是"收集正式版崩溃"的产品:penaltyLog 适合诊断,penaltyDeath 适合在调试或测试中尽早失败;把它当作 release 崩溃收集器会制造噪声,也不能替代崩溃 SDK 或质量门禁。

java 复制代码
StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder()
        .detectDiskReads()
        .detectDiskWrites()
        .detectNetwork()
        .penaltyLog()
        .build());

策略应按 build variant 和团队门禁配置。违规报告说明"此处发生了不该发生的操作",不等于已经完成用户可见的错误恢复。

生产陷阱

  • 吞掉异常:空 catch 会让失败消失,资源和 UI 状态可能同时失真。
  • 捕获 Throwable:会吞掉严重错误和运行时控制信号,妨碍正确终止。
  • 记录秘密,或只记录 message:前者造成隐私事故,后者丢失 stack、cause、操作和关联上下文。
  • 把受检异常直接抛过 UI 边界:界面被 I/O 细节污染,无法决定重试、登录还是降级。
  • UncaughtExceptionHandler 当恢复路径:它属于致命边界,不提供一致、安全的应用状态。
  • 把 StrictMode 当 release 崩溃收集器:它是开发期策略探针,不是线上可靠性平台。

可改造练习:把失败变成可恢复界面

将一个会直接抛出 IOExceptionrepository.fetchOrders() 改成以下练习。

  1. 在一次点击"刷新订单"时生成 correlationId,并把它传入 repository、UseCase 和日志。
  2. 在 Repository 边界把 IOException 映射为 DomainError.NETWORK,使用 Log.e 记录 event=orders_refresh_failed、cid、操作、非敏感网络类别和 throwable。
  3. 在 ViewModel 将 DomainError.NETWORK 转为 OrdersUiState.RetryableError(correlationId);认证错误转为登录状态,数据错误转为可理解的提示。
  4. 点击重试时生成新的 correlation id,同时保留前一次 id 供支持人员对照;不要复用旧请求的异常对象。

验收时故意断网:界面必须显示可操作重试,不得崩溃;Logcat 中应能以 cid 找到异常 stack 与操作字段;不应出现 token 或完整响应体。

上线前检查

  • 受检 I/O 异常是否在数据或领域边界翻译,而非泄漏到 UI?
  • 是否保留 cause、stack trace、事件名和 correlation id,同时避免敏感数据?
  • 每个错误类别是否对应明确的 UI 动作或降级状态?
  • 崩溃报告是否与恢复路径分离,并且 SDK 行为已按版本验证?
  • StrictMode 是否只按合适的开发、测试或受控构建策略启用?
  • try-with-resources 是否覆盖了应用拥有的可关闭资源?

面试表达: "我不把异常等同于崩溃:在数据边界把技术异常映射成领域错误,携带 cause 和关联日志;ViewModel 再把领域错误映射成可恢复 UI 状态。未捕获异常只交给运行时和崩溃收集,StrictMode 则用于尽早发现不该发生的 I/O 与泄漏。"

下一步可以把同一套边界应用到 I/O 与存储:比较 ContentResolver、文件缓存和数据库事务各自的资源所有权、原子性与错误翻译策略。

相关推荐
砍材农夫2 小时前
spring|spring web|拦截器、过滤器、aop
java·spring boot·spring·spring cloud
mldong9 小时前
一个 App,十三套后端:手机审批端 uni-jeeflow-app 开源了
java·架构
tqs_1234510 小时前
MySQL RR隔离级别死锁|Gap间隙锁、临键锁,订单并发范围查询死锁根因方案
java
逆境不可逃10 小时前
Pi Agent 学习笔记:多个工具怎样并行执行
java
Flynt11 小时前
Java 27 升级实测:默认值动得比新特性多,有个老参数会让 JVM 直接起不来
java·jvm·后端
Bs_MoneyMagnet11 小时前
基于springboot+vue的个人健康管理系统的设计与实现 源码+文档
java·vue.js·spring boot·后端·vue3·springboot3·计算机毕业设计
星云API技术支持11 小时前
企业微信二次开发:群权限设置、成员管理与群资料维护的接口组合实践
java·前端·企业微信
niucloud-admin11 小时前
JAVA V6 多商户商城 开发文档——job 计划任务开发
java·python·github
海宇AI12 小时前
零信任架构实战:基于海宇柠檬查出险-登记证构建自动化车抵贷核保网关
java·人工智能·架构·自动化