订单页偶发显示空白:仓库层读取响应体时抛出
IOException,ViewModel 只记录了e.getMessage(),主线程随后收到一个没有业务含义的异常。开发者既不知道是哪一次请求失败,也不知道用户看到了什么结果。问题不在于"有没有 catch",而在于异常跨层时有没有被翻译、记录和交付。
一句话结论: Java 异常应在最了解失败语义的层被转换为可诊断的领域错误,UI 只消费可恢复状态,而日志、崩溃上报和 StrictMode 分别承担观察、终止边界通知和开发期约束。
本文以 Java 17 语言语义、Android 14 附近的 AOSP 概念路径为边界;android.util.Log、RuntimeInit 与 StrictMode 的内部细节会随 Android 版本变化,第三方崩溃 SDK 的实现更不是 Android 平台契约。
先看失败如何抵达 UI
受检异常(如 IOException)要求调用方声明或处理,适合表示调用者可预期且能采取动作的 I/O 失败;运行时异常(如 NullPointerException、IllegalArgumentException)通常表示编程错误或违反前置条件。两者都可以携带 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;它适合在数据边界被转换为Network、Storage之类的错误。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.d、Log.w、Log.e |
开发者、测试系统或日志采集管道读取 |
Java Thread.UncaughtExceptionHandler,Android 概念入口为 RuntimeInit 的未捕获处理 |
某线程上未被处理的 throwable 到达线程根部 | Thread 的 handler 链与运行时边界持有当前失败 | 线程退出前的未捕获异常 | 默认处理器记录致命信息并走进程终止路径 |
| 崩溃收集 SDK,示例为 Crashlytics 的 handler 包装 | SDK 初始化时注册自己的收集器 | SDK 进程内缓存和它的上传队列持有报告 | 未捕获崩溃或 SDK 显式记录非致命错误 | SDK 服务端与团队控制台接收报告 |
StrictMode,入口为 StrictMode.setThreadPolicy 和 setVmPolicy |
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 收到 Retry、Login 或提示状态,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 崩溃收集器:它是开发期策略探针,不是线上可靠性平台。
可改造练习:把失败变成可恢复界面
将一个会直接抛出 IOException 的 repository.fetchOrders() 改成以下练习。
- 在一次点击"刷新订单"时生成
correlationId,并把它传入 repository、UseCase 和日志。 - 在 Repository 边界把
IOException映射为DomainError.NETWORK,使用Log.e记录event=orders_refresh_failed、cid、操作、非敏感网络类别和 throwable。 - 在 ViewModel 将
DomainError.NETWORK转为OrdersUiState.RetryableError(correlationId);认证错误转为登录状态,数据错误转为可理解的提示。 - 点击重试时生成新的 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、文件缓存和数据库事务各自的资源所有权、原子性与错误翻译策略。