MarkZ

先从一个最小例子开始

假设业务里要读取一个开关:

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 就是在核心代码和复杂世界之间,放一个稳定、可控、能兜底的入口。


相关笔记