先从一个最小例子开始
假设业务里要读取一个开关:
val enableAnimation = remoteConfig.getBoolean("enable_animation")一开始这样写没问题。后来需求变多了:
- 远程配置可能没初始化
- key 可能不存在
- 读取可能抛异常
- 没网时要用默认值
- 需要打印一次降级日志
- 不同宿主的默认值还不一样
如果每个业务点都直接调用remoteConfig,代码很快会变成这样:
val enableAnimation = try {
if (remoteConfig.isReady()) {
remoteConfig.getBoolean("enable_animation")
} else {
false
}
} catch (e: Throwable) {
false
}问题不是这段代码不能工作,而是每个调用方都被迫知道远程配置的脏细节。
这时候可以加一层 Wrapper:
class FeatureSwitchWrapper(
private val remoteConfig: RemoteConfig?
) {
fun isAnimationEnabled(): Boolean {
return try {
remoteConfig?.takeIf { it.isReady() }
?.getBoolean("enable_animation")
?: false
} catch (e: Throwable) {
false
}
}
}业务代码变成:
if (featureSwitchWrapper.isAnimationEnabled()) {
playAnimation()
}这就是 Wrapper 最朴素的价值:调用方只问一个业务问题,复杂判断由 Wrapper 收走。
Wrapper 到底是什么?
Wrapper 是放在核心代码和复杂对象之间的一层稳定入口。
flowchart LR Core["核心代码<br/>只关心业务动作"] Wrapper["Wrapper<br/>稳定入口<br/>隐藏细节<br/>统一兜底"] Complex["复杂对象<br/>SDK / Hook / 配置 / 文件 / 网络 / 平台能力"] Core -->|"调用业务语义"| Wrapper Wrapper -->|"处理底层细节"| Complex
没有 Wrapper 时:
核心代码 -> 复杂对象有 Wrapper 时:
核心代码 -> Wrapper -> 复杂对象Wrapper 的重点不是“多套一层类”,而是把不该泄漏给核心代码的复杂性挡住。
Wrapper 不是“依赖隔离”的同义词
依赖隔离只是 Wrapper 的一个使用场景。
Wrapper 更通用,它隔离的是变化、失败、差异和脏细节。
| 场景 | 直接调用的问题 | 加 Wrapper 后 |
|---|---|---|
| 第三方 SDK | 调用方到处 import SDK,宿主缺能力时容易编译或运行失败 | core 只依赖稳定接口,真实实现和空实现分开 |
| Hook 脚本 | 路径可能变化,脚本可能不存在,失败会污染主流程输出 | 主流程只调用稳定入口,找不到脚本就跳过或记录 |
| 文件系统 | 路径、权限、文件是否存在的判断散落各处 | Wrapper 统一路径选择、存在性检查和错误处理 |
| 网络请求 | 超时、重试、错误码映射散落在业务里 | Wrapper 统一超时、重试、降级和返回模型 |
| 平台能力 | 不同 Android 版本或不同宿主能力不同 | Wrapper 屏蔽平台差异,对外给稳定能力 |
| 实验配置 | 开关、灰度、默认值、降级逻辑分散 | Wrapper 统一配置读取、默认值和实验兜底 |
| 埋点统计 | 统计失败不应该影响业务主流程 | Wrapper 捕获失败,必要时静默跳过 |
所以 Wrapper 的关键词不是“依赖隔离”,而是:稳定入口、隐藏细节、集中兜底、收拢变化。
没有 Wrapper 时会发生什么?
复杂性会向调用方扩散
以 PAG 动效为例,如果没有 Wrapper,多个地方都会直接知道 PAG:
if (hasPag) {
pagView.play()
}try {
pagView.setComposition(composition)
} catch (e: Throwable) {
// ignore
}<org.libpag.PAGView
android:id="@+id/background_pag"
android:layout_width="match_parent"
android:layout_height="match_parent" />结构上会变成这样:
flowchart LR A["ButtonView<br/>判断 hasPag<br/>try-catch"] B["TransitionManager<br/>直接调用 play / stop"] C["XML<br/>直接声明 PAGView"] D["业务调用方<br/>知道 PAG 细节"] SDK["PAG SDK"] A --> SDK B --> SDK C --> SDK D --> SDK
这时某个底层规则一变,就可能牵连很多文件。
底层类型会污染核心代码
如果核心代码里出现:
private var backgroundPag: PAGView? = null或者方法签名里出现:
fun play(view: PAGView)调用方就已经被 PAGView 绑定了。
即使你在使用前写了 if (hasPag),无 PAG 的宿主仍然可能在编译期或 XML inflate 阶段失败。
⚠️ 真正的 Wrapper 不只是把方法调用挪到另一个类里。它要阻止底层类型从 import、字段、参数、返回值、XML、配置路径里泄漏出去。
一个完整 Wrapper 通常包含什么?
Wrapper 不是单个方法,它通常包含五个部分:
flowchart TD Contract["稳定契约<br/>核心代码只依赖它"] Real["真实实现<br/>调用真实 SDK / Hook / 网络"] Noop["空实现或降级实现<br/>能力不存在时安全返回"] Selector["选择逻辑<br/>按宿主 / 配置 / 构建开关选择实现"] Failure["失败策略<br/>no-op / 默认值 / 重试 / 抛错 / 记录日志"] Selector -->|"注入某个实现"| Contract Contract --> Real Contract --> Noop Failure -->|"约束失败行为"| Real Failure -->|"约束降级行为"| Noop
稳定契约
契约要站在核心代码视角设计。
不要问:
底层 API 有哪些方法?而要问:
核心代码真正想完成什么动作?比如按钮要的是“播放动画”,不是“操作 PAGView”。
所以契约应该表达业务动作:
interface ButtonAnimationWrapper {
fun createAnimationView(context: Context): View
fun setSource(view: View, source: String?)
fun play(view: View)
fun stop(view: View)
fun release(view: View)
}注意这里暴露的是普通 View,不是 PAGView。
真实实现
有 PAG 的宿主使用真实实现:
class PagButtonAnimationWrapper : ButtonAnimationWrapper {
override fun createAnimationView(context: Context): View {
return PAGView(context)
}
override fun setSource(view: View, source: String?) {
val pagView = view as? PAGView ?: return
if (source.isNullOrEmpty()) return
// 这里才允许出现 PAG 的真实 API
}
override fun play(view: View) {
(view as? PAGView)?.play()
}
override fun stop(view: View) {
(view as? PAGView)?.stop()
}
override fun release(view: View) {
(view as? PAGView)?.freeCache()
}
}空实现
无 PAG 的宿主使用空实现:
class NoopButtonAnimationWrapper : ButtonAnimationWrapper {
override fun createAnimationView(context: Context): View {
return View(context)
}
override fun setSource(view: View, source: String?) {
// no-op
}
override fun play(view: View) {
// no-op
}
override fun stop(view: View) {
// no-op
}
override fun release(view: View) {
// no-op
}
}这样核心按钮可以永远调用 Wrapper。
有 PAG 时播放动效;没有 PAG 时按钮仍然可用,只是动效降级。
案例一:通用按钮 PAG 能力
原始问题是什么?
通用按钮是基础能力,PAG 动效是增强能力。
如果通用按钮内部直接依赖 PAG,会变成:
只要编入通用按钮
↓
就可能被迫需要 PAG
↓
没有 PAG 的宿主编译或运行失败典型耦合点包括:
- Kotlin import 直接引用
org.libpag.PAGView - 字段类型是
PAGView - 动画管理器直接调用
play/progress/isReady - XML 直接声明
<org.libpag.PAGView>
Wrapper 后的结构
flowchart TD subgraph Core["button core"] View["NadGeneralButtonView<br/>按钮主逻辑"] Manager["NadButtonTransitionManager<br/>状态和转场逻辑"] Delegate["NadButtonPagDelegate<br/>稳定契约<br/>不 import org.libpag.*"] Xml["nad_general_button_view.xml<br/>FrameLayout 占位"] end subgraph ImplGroup["pag/impl<br/>BUILD_WITH_PAG=1"] Impl["NadButtonPagDelegateImpl<br/>真实 PAG 实现<br/>允许 import org.libpag.*"] end subgraph StubGroup["pag/stub<br/>BUILD_WITH_PAG=0"] Stub["NadButtonPagDelegateImpl<br/>空实现<br/>不依赖 PAG"] end View --> Delegate Manager --> Delegate Xml --> View Impl -.->|实现| Delegate Stub -.->|实现| Delegate
XML 不能再直接写 PAGView:
<org.libpag.PAGView
android:id="@+id/background_pag"
android:layout_width="match_parent"
android:layout_height="match_parent" />要改成普通容器:
<FrameLayout
android:id="@+id/background_pag_container"
android:layout_width="match_parent"
android:layout_height="match_parent" />真实 PAGView 由 pag/impl 里的 Wrapper 在运行时创建,然后塞进这个容器。
无 PAG 场景下,pag/stub 可以返回普通 View 或什么都不做。
调用过程
sequenceDiagram participant View as NadGeneralButtonView participant Delegate as NadButtonPagDelegate 稳定契约 participant Impl as pag/impl 真实 PAG participant Stub as pag/stub 空实现 View->>Delegate: createPagView(container) alt BUILD_WITH_PAG=1 Delegate->>Impl: 创建 PAGView Impl-->>Delegate: 返回普通 View 类型 else BUILD_WITH_PAG=0 Delegate->>Stub: no-op 或普通 View Stub-->>Delegate: 返回普通 View 类型 end Delegate-->>View: View View->>Delegate: play(view)
这个案例里,Wrapper 隔开的不是单点“依赖”,而是一组复杂性:
- 宿主是否有 PAG
- 编译期是否能看到 PAG 类
- XML inflate 是否会找不到类
- 动效失败是否影响按钮主功能
- PAG API 以后是否变化
案例二:PostToolUse Hook 稳定入口
原始问题是什么?
Codex hook 原来直接调用插件缓存目录里的脚本:
{
"command": "python3 \"${PLUGIN_ROOT}/hooks/skill-telemetry.py\""
}当 ${PLUGIN_ROOT} 解析到版本化缓存目录时,插件升级就可能让旧路径失效:
~/.codex/plugins/cache/personal/iplugin/0.13.0升级后真实脚本在:
~/.codex/plugins/cache/personal/iplugin/0.13.5这类 hook 是辅助能力,失败不应该污染每一次工具调用的输出。
Wrapper 后的结构
flowchart LR Config["Codex hook 配置<br/>只调用稳定路径"] Wrapper["$HOME/.codex/hooks/<br/>iplugin-skill-telemetry.py<br/>稳定 Wrapper"] Script["真实统计脚本<br/>skill-telemetry.py"] Log[("$HOME/.codex/skill-usage.jsonl")] Config -->|"PostToolUse"| Wrapper Wrapper -->|"找到后转发"| Script Script -->|"写使用记录"| Log
Codex hook 配置只保留稳定入口:
{
"hooks": {
"PostToolUse": [
{
"matcher": "*",
"hooks": [
{
"type": "command",
"command": "python3 \"$HOME/.codex/hooks/iplugin-skill-telemetry.py\"",
"timeout": 5
}
]
}
]
}
}Wrapper 负责找真实脚本:
#!/usr/bin/env python3
import os
import runpy
from pathlib import Path
CODEX_HOME = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")).expanduser()
def latest_cached_script() -> Path | None:
cache_root = CODEX_HOME / "plugins/cache/personal/iplugin"
if not cache_root.exists():
return None
scripts = []
for version_dir in cache_root.iterdir():
script = version_dir / "hooks" / "skill-telemetry.py"
if script.is_file():
scripts.append(script)
if not scripts:
return None
return max(scripts, key=lambda script: script.stat().st_mtime)
def candidates() -> list[Path]:
values = []
override = os.environ.get("IPPLUGIN_TELEMETRY_SCRIPT")
if override:
values.append(Path(override).expanduser())
values.append(Path("/Users/markz/code/tools/iplugin/hooks/skill-telemetry.py"))
cached = latest_cached_script()
if cached:
values.append(cached)
return values
def main() -> None:
for script in candidates():
if script.is_file():
try:
runpy.run_path(str(script), run_name="__main__")
except Exception:
pass
return
if __name__ == "__main__":
main()这个 Wrapper 隔开的东西包括:
- 插件缓存目录版本变化
- 旧目录被删除
- 脚本不存在
- 脚本执行失败
- 非核心统计逻辑污染工具主流程
怎么判断该不该加 Wrapper?
可以用这个决策图:
flowchart TD Start([开始]) Q1{核心代码是否直接调用某个复杂对象?} Q2{它会变化、缺失或失败吗?} Q3{失败后主流程仍应继续吗?} Q4{调用细节是否散落多处?} Q5{未来可能有多个实现吗?} W1["考虑 Wrapper<br/>在 Wrapper 里写清楚降级策略"] W2["考虑 Wrapper<br/>集中错误处理和调用细节"] W3["考虑 Wrapper"] Direct["可以直接调用"] NoWrap["暂时不要加 Wrapper"] End([结束]) Start --> Q1 Q1 --> Q2 Q2 -->|是| Q3 Q2 -->|否| Q5 Q3 -->|是| W1 Q3 -->|否| Q4 Q4 -->|是| W2 Q4 -->|否| Direct Q5 -->|是| W3 Q5 -->|否| NoWrap W1 --> End W2 --> End W3 --> End Direct --> End NoWrap --> End
更简单的判断标准:
这层 Wrapper 能不能让核心代码少知道一些东西?如果答案是不能,那它大概率只是多余转发。
设计 Wrapper 的步骤
第一步:找出复杂点
问自己:
- 哪个东西可能不存在?
- 哪个东西以后可能替换?
- 哪个东西失败后不应该影响主流程?
- 哪些判断正在散落到很多文件?
- 哪个底层类型正在污染核心代码?
第二步:从核心代码视角设计契约
不要照抄底层 API。
应该问:
核心代码真正想完成什么动作?比如按钮要的是“播放动画”,不是“操作 PAGView”。
Hook 要的是“记录 skill 使用”,不是“执行某个版本目录下的 Python 文件”。
第三步:明确失败策略
Wrapper 一定要写清楚失败怎么办。
| 策略 | 适用场景 |
|---|---|
| no-op | 增强能力缺失,如无 PAG 动效 |
| 默认值 | 配置读取失败 |
| 重试 | 网络或 IO 短暂失败 |
| 降级实现 | 高配能力不可用时用低配能力 |
| 记录日志后跳过 | 埋点、统计、非核心 hook |
| 明确抛错 | 核心能力失败,调用方必须知道 |
Wrapper 不能只包成功路径。
如果失败策略没想清楚,Wrapper 就只是一层转发。
第四步:检查边界有没有漏
检查这些地方:
- import 是否还有底层 SDK
- 字段类型是否还有底层类型
- 方法参数和返回值是否还有底层类型
- XML 是否直接写了底层 View
- 配置是否写死了版本路径
- 调用方是否还在写底层判断
如果有,说明 Wrapper 边界还没封住。
好 Wrapper 和坏 Wrapper 的区别
好的 Wrapper
好的 Wrapper 会让调用方更简单:
animationWrapper.play(view)调用方不需要知道:
- 背后是真 PAG 还是空实现
- PAG 类是否存在
- 失败要不要 try-catch
- 资源释放怎么做
坏的 Wrapper
坏 Wrapper 只是换了个名字:
class BadPagWrapper(private val pagView: PAGView) {
fun play() {
pagView.play()
}
}它没有解决问题,因为:
- 构造参数还是
PAGView - 调用方还是得先拿到
PAGView - 调用方还是依赖 PAG
- 失败策略没有集中
- 只是把
pagView.play()挪了地方
真正的 Wrapper 不是“套壳”,而是建立边界。
什么时候不要用 Wrapper?
Wrapper 不是越多越好。
不适合用的情况:
- 只有一个稳定调用点,没有变化点
- 失败就应该直接失败,不需要兜底
- 底层能力就是当前模块的核心能力
- 包一层后只是转发,没有减少调用方复杂度
- 为了追求架构感,把简单代码拆得更难读
如果一层 Wrapper 不能让核心代码更简单、更稳定、更少知道底层细节,那就先不要加。
最后记住什么?
Wrapper 的本质是:
核心代码 -> 稳定入口 -> 复杂世界它的价值不是“封装一下”,而是把这些东西挡在核心代码外面:
- 变化
- 失败
- 差异
- 路径
- 版本
- SDK 细节
- 平台细节
- 降级策略
以后看到这种代码,就应该想到 Wrapper:
到处判断有没有某能力
到处 try-catch 某个外部调用
到处 import 某个可选 SDK
到处写死某个路径或版本号
到处处理同一种失败和降级一句话:Wrapper 就是在核心代码和复杂世界之间,放一个稳定、可控、能兜底的入口。