SDK架构设计原则:模块化、可扩展性、兼容性

做SDK架构设计,说白了就是回答三个问题:
第一,别人怎么用你?
第二,别人怎么扩展你?
第三,你变了别人怎么办?

这三个问题对应三个原则——模块化、可扩展性、兼容性。它们之间不是孤立的,而是互相制约的。我见过不少团队把模块化做得特别漂亮,结果扩展性一塌糊涂;也见过兼容性做得滴水不漏,但代码臃肿到没人敢动。

我们来看看,怎么平衡这三者。

模块化:把SDK拆成积木块

为什么要模块化?不是为了让代码好看,是为了让用户可以按需加载。

我遇到过这样的项目:一个聊天SDK,集成了图片编辑、视频处理、朋友圈、支付……总共80多MB。接入方只想用个文字聊天功能,结果不得不带上所有依赖。这就是模块化没做好的后果。

模块化的核心原则:

  • 松散耦合:模块之间通过接口通信,不直接依赖实现
  • 接口隔离:每个模块只暴露最小必要接口
  • 高内聚:一个模块只做一件事,并且做得彻底

举个例子,一个支付SDK的模块划分:

sdk-payment/
├── core/               # 核心协议接口
│   ├── PaymentProvider.java
│   └── PaymentCallback.java
├── alipay/             # 支付宝实现
│   └── AlipayProvider.java
├── wechat-pay/         # 微信支付实现
│   └── WechatPayProvider.java
├── unionpay/           # 银联实现
│   └── UnionPayProvider.java
└── api/                # 对外暴露的API
    └── PaymentSDK.java

你看,核心模块只定义接口,具体支付方式各自独立实现。接入方只需要依赖core和需要的支付模块就行。

我个人习惯的做法:先梳理SDK的所有功能。把功能画成一张依赖图,从最底层的基础能力(网络、存储、线程)往上画。然后按「稳定度」分组——底层不变的分一组,上层多变的分一组。这样拆出来的模块,既清晰又容易维护。

可扩展性:预留的「变」的空间

SDK一旦发布出去,就不是你能完全掌控的了。用户会提出千奇百怪的需求——换个加密算法、改个UI风格、接个第三方插件……

所以,可扩展性不是「以后再说」,而是一开始就要想好的事。

我常用的几种扩展手段:

  1. 策略模式——算法可以替换
  2. 工厂模式——创建逻辑可以替换
  3. 插件化——实现模块可以热插拔
  4. 回调与钩子——在关键节点开放接入点

比如日志模块的扩展设计:

// 核心接口
public interface LogProvider {
    void log(String tag, String message);
}

// 默认实现——写到文件
public class FileLogProvider implements LogProvider {
    @Override
    public void log(String tag, String message) {
        // 写入本地日志文件
    }
}

// 用户自定义实现——上报到服务器
public class UploadLogProvider implements LogProvider {
    @Override
    public void log(String tag, String message) {
        // 通过网络上传
    }
}

接入方只要实现LogProvider接口,就能替换掉整个日志行为。你想想看,如果不留这个扩展点,人家想加个日志上报功能怎么办?只能改你的SDK源码——那就完蛋了,版本锁死,以后升级都没法升。

重要提醒:预留扩展点要有度。我见过有人给每个方法都加回调,接口膨胀得吓人。扩展点只加在「用户确实可能有差异」的地方,其他的保持简单。

兼容性:守护用户的代码

兼容性是最容易被忽视的,但恰恰是影响用户迁移成本的关键。SDK升级一次,用户就得改一次代码,谁还敢用你?

兼容性主要分三个层面:

类型 含义 举例
二进制兼容 旧版本的类文件可以直接用新版本替换 加了新方法但没删旧方法,没改签名
源码兼容 旧版本的代码可以不修改就能编译通过 没改原有API的参数和返回值
行为兼容 同样的输入,新版本和旧版本输出相同 修复bug但没改功能逻辑

最容易破坏兼容性的操作:

  • 删掉公开类或方法(禁止)
  • 修改方法的参数列表(哪怕只是加一个默认参数)
  • 更改返回类型
  • 把public改成private
  • 修改异常类型

我曾经踩过一个坑:有个版本我把一个类的构造方法从无参改为带参,心里想的是「反正启动时需要传配置」。结果SDK发布后,用户那边直接编译失败,因为大量代码用了new MySDK()。那次事故我记到现在——构造方法的变化是破坏兼容性的大雷区,能不动就不动。

兼容性策略:

  1. 旧接口保留,新接口新增——旧的标记为@Deprecated,但不删除
  2. 版本号语义化——主版本变说明不兼容,次版本变说明新增功能但兼容,补丁变说明修bug
  3. 行为不变——修复bug不能改变原有逻辑的预期行为
  4. 测试覆盖——每个版本跑一次回归测试,确保旧用例都通过

模块化、可扩展性、兼容性,这三者的关系可以用一张图来理解:

SDK架构设计三大原则 模块化 每个模块职责单一 模块间通过接口通信 支持按需加载 可扩展性 预留策略与插件点 支持功能替换 热插拔能力 兼容性 旧接口保留不删 语义化版本控制 行为一致 三者互相制约,需要权衡 过度模块化可能导致扩展困难,过度追求兼容可能阻碍创新 设计原则:保持简单,预留变化,守护兼容

这三个原则不是各自为政,而是互相关联的。模块化做好了,可扩展性就有基础——因为接口清晰,替换实现成本低。兼容性又是模块化和可扩展性的「安」——保证你变的时候,用户不受影响。

我的实际经验:在做一个跨平台SDK时,我先把功能按稳定度分层:底层网络/存储/日志是稳定的核心,上层业务逻辑是易变部分。核心层做成动态库,不轻易改接口;业务层做成插件形式,用户可以按需加载。版本发布时,核心层尽量只增加不修改,保证二进制兼容;业务层更新频繁但影响面小,通过语义化版本号让用户知道升级风险。

这样一套思路下来,后来用户升级SDK基本只用改build.gradle里的版本号,代码几乎不用动。这才是架构设计该有的样子——让用户感觉不到变化,但功能在稳步提升。