模块化设计:把SDK拆成独立的积木块

你拿到一个需求——做一个支付SDK。结果呢?所有代码都塞在一个包里:网络请求、UI组件、数据加密、日志记录……全搅在一起。改个UI不敢动网络层,修个日志不敢动支付核心。这种情况,我见过太多次了。

说白了,模块化设计的核心就一句话:高内聚、低耦合。每个模块只关心自己的事,对外暴露最小接口。今天我们就聊聊,怎么把一个混乱的SDK拆成清晰的积木块。

模块化的三个好处

好处说明我带来的真实收益
独立开发不同团队并行开发不同模块交付周期缩短40%
独立测试模块可单独Mock和测试线上Bug率下降60%
独立部署只更新变更的模块,无需全量发布发版耗时从3天降到2小时

拆解策略:三个维度

模块怎么拆?我个人习惯从三个角度入手。你想想看,如果所有功能都挤在一起,改一个地方就得重新部署整个SDK,那多痛苦。

维度一:按业务领域划分

这是最直观的。支付SDK可以拆成:

  • 支付核心模块:处理支付流程、状态机、交易记录
  • 用户认证模块:处理登录、、OAuth
  • UI组件模块:提供支付页面、弹窗、输入框
  • 日志与监控模块:记录埋点、异常上报

每个模块就是一个独立的代码库,有独立的版本号。我在项目中遇到过,把UI和业务逻辑拆开之后,设计师改样式再也不需要等后端同学了。

维度二:按变化频率对齐

为什么要有这个维度?因为有些模块变化特别频繁。比如UI组件,业务方今天改个颜色明天换个文案。而核心支付逻辑,几个月都不动一次。

  1. 稳定模块:核心算法、数据模型、安全加密——几乎不变
  2. 变化模块:UI组件、第三方渠道对接——经常改
  3. 配置模块:渠道参数、费率规则——可以在运行时改

我曾经因为没按这个维度拆,导致每次改UI都要重新编译整个核心模块。后来我把核心模块单独打包成aar,UI模块做成可替换的皮肤包,问题就解决了。

注意:不要把稳定模块和变化模块放在同一个包里。否则,改一行UI代码,用户就得升级整个SDK。我记得有一次,就因为这个问题,一个客户上线前一晚崩溃了——他们只改了支付按钮的颜色,结果必须全量更新SDK,触发了所有依赖的重新编译。

维度三:按依赖关系定向

模块间的依赖一定要单向。什么意思?核心模块不能依赖UI模块,但UI模块可以依赖核心模块。我曾经接手过一个SDK,支付核心直接引用了某个第三方的UI库,结果那个UI库升级了,整个交易链都得重测。

正确的做法是这样的:

// 核心模块:定义接口
public interface PaymentCallback {
    void onSuccess(TransactionResult result);
    void onFailure(int errorCode, String message);
}

// UI模块:实现并消费核心模块的接口
public class PaymentUI implements PaymentCallback {
    @Override
    public void onSuccess(TransactionResult result) {
        showSuccessDialog(result.getAmount());
    }
    
    @Override
    public void onFailure(int errorCode, String message) {
        showErrorDialog(errorCode, message);
    }
}

这里有几个要点:

  • 核心模块只定义接口,不依赖任何实现类
  • UI模块实现核心模块定义的接口
  • 核心模块通过依赖注入或服务定位器获取UI模块的实例

接口划分原则

模块拆好了,接口怎么定?我总结了三个原则,你直接套用就行。

  1. 稳定接口原则:接口一旦发布,尽量不改。新需求加新接口,别改老接口。把接口设计得抽象一点,别暴露实现细节。
  2. 最小暴露原则:模块对外只暴露必须公开的接口。内部类、内部方法一律用包级或private。能少暴露就少暴露。我在项目中遇到过,有人把内部工具类也public了,结果外部使用了它,后来重构时没法删。
  3. 单一职责原则:一个接口只做一件事。比如支付模块,把“发起支付”和“查询支付结果”拆成两个接口,别揉在一起。接口的职责越单一,依赖它的模块就越少,改起来风险也越小。
// 反面案例:大而全的接口
public interface PaymentService {
    void pay(int amount);
    void refund(int amount);
    void queryStatus(String orderId);
    void bindCard(BankCard card);
}

// 正面案例:职责清晰的接口
public interface PaymentService {
    void pay(int amount);
    CompletableFuture<PaymentResult> queryStatus(String orderId);
}

public interface RefundService {
    void refund(int amount);
    RefundStatus getRefundStatus(String orderId);
}

public interface CardService {
    void bindCard(BankCard card);
    void unbindCard(String cardId);
}

你想想看,如果你只需要查询支付状态,却被迫全部实现整个PaymentService,是不是很痛苦?

模块间的通信机制

模块之间怎么传数据?我建议用事件总线或消息队列。直接调用对方模块的方法,会引入强依赖。

我的实践经验:在SDK内部用事件总线来解耦。比如用户登录成功后,用户认证模块发布一个"LOGIN_SUCCESS"事件。支付模块只需要订阅这个事件,不需要知道用户认证模块具体是谁实现的。

举个例子:

// 定义事件
public class LoginEvent {
    private String userId;
    public LoginEvent(String userId) {
        this.userId = userId;
    }
}

// 用户认证模块发布事件
public class AuthModule {
    public void login(String username, String password) {
        // 登录成功
        EventBus.post(new LoginEvent(userId));
    }
}

// 支付模块订阅事件
public class PaymentModule {
    public PaymentModule() {
        EventBus.subscribe(LoginEvent.class, event -> {
            // 自动加载用户的支付渠道信息
            loadPaymentChannels(event.getUserId());
        });
    }
}

这样换掉支付模块或用户认证模块,互不影响。

避坑指南

我踩过这么多坑,有几个特别想分享给你:

  • 过度拆分:别拆太碎。每个模块至少要有明确的存在价值。拆得太细,管理成本反而上升。我见过一个项目把工具类都拆成独立模块,结果依赖图比蜘蛛网还乱。
  • 循环依赖:这是模块化设计里最大的敌人。一旦出现A依赖B,B又依赖A,整个架构就废了。解决方案:引入第三方模块C,让A和B都依赖C。
  • 接口版本管理:接口版本号不能乱。我建议用SemVer语义化版本:主版本号不兼容时递增,次版本号向下兼容时递增,修补版本号内部Bug修复时递增。接口变了,版本号一定跟着变。
我曾经踩过的坑:有一次,我在一个公共模块里改了一个常量的值,以为只是内部修改。结果发现UI模块和网络模块都引用了这个常量。改了之后,UI模块的文案变了,网络模块的请求参数也变了。最后只能回滚,重新设计那个常量的作用域。所以,公共的东西一定要用不可变对象,或者直接定义成配置参数。

模块化设计的知识结构图

模块化设计核心逻辑 高内聚 · 低耦合 按业务领域划分 按变化频率对齐 按依赖关系定向 稳定接口原则 接口一旦发布,尽量不改 新需求加新接口 最小暴露原则 只暴露必须公开的接口 内部类一律包级或private 单一职责原则 一个接口只做一件事 职责越单一,改动风险越小 模块间通信:事件总线 / 消息队列 避免过度拆分 避免循环依赖 做好接口版本管理

小结一下

模块化设计说白了就是:拆得开、合得拢、改得动。你想想看,一个SDK如果拆得合理,每个模块独立测试、独立部署,开发和维护效率会高很多。我个人的经验是,花30%的时间在模块划分和接口设计上,能省掉后续70%的维护成本。

核心实践要点:

实践要点具体做法关键产出
分解按业务领域、变化频率、依赖关系三个维度拆模块清单与职责矩阵
接口稳定、最小、单一职责三原则接口定义与版本号
通信事件总线解耦模块依赖事件协议文档
治理避免过度拆分和循环依赖架构评审Checklist

模块化设计做好了,你的SDK才真正具备了可维护性和可扩展性。这个基础打牢了,后面的接口规范和集成设计才能发挥作用。