在公司做了一年多的 SDK 开发,结合自己的所知所学,分享一些 SDK 开发的经验。

1. SDK 是什么

相信做 Android 开发的朋友,一定使用过第三方的 SDK,比如推送 SDK、分享 SDK 等。SDK 的全称是 Software Development Kit,翻译为“软件开发工具包”。SDK 通常是为辅助开发某类软件而编写的特定软件包、框架集合等。

SDK 可以分为系统 SDK 和应用 SDK。所谓系统 SDK 是为使用特定的软件框架、硬件平台等所开发的工具集合。而应用 SDK 则是基于系统 SDK 开发的独立于具体业务、拥有特定功能的工具集合。

SDK 的使用者主要是 B 端客户,最终交付产品是代码、示例和文档,客户接入 SDK 也是和 SDK 提供方交流的过程,对外沟通的成本比对内更高,遇到的问题也会更多。所以 SDK 开发对开发者的要求比对应用开发更高。能开发好 SDK 一定能开发好应用,但能开发好应用,未必能开发好 SDK。

2. SDK 实现目标

SDK 的实现目标,概括来说:简洁、稳定、高效。

简洁

对于用户而言,一款好的产品应该是简洁易用的,不该让他们花费太长的时间学习。SDK 也当如此,它不该出现复杂繁琐的对接工作,使用者通过阅读代码和文档,花费很少的时间就能做好 SDK 的对接。

比如当开发者需要使用 SDK 的服务时,只需要在代码中新增一行即可。在项目中初始化 SDK 只要一行代码,开发者不用关心 GLContext,内部已做好处理,也不用关心同步或异步问题。

public class FURenderer {
    // 定义    
    public static void setup(Context context) {
        //...
    }
}
// 一行代码调用
FURenderer.setup(context);

稳定

站在 SDK 使用者角度来看,我们期望第三方 SDK 的服务是稳定高效的,体现在提供稳定可靠的服务,同时运行时性能要高效。这就要求我们在设计实现 SDK 时要尽可能做到以下几点:

  • 对外提供稳定的 API。SDK 的 API 一旦确定,除非特殊情况不可更改,提供方变更 API 的成本非常大。
  • 对外提供稳定的业务。在提供了稳定的 API 后,必须要有稳定的业务作为支撑。
  • 运行时的稳定。确保 SDK 自身稳定运行,不能出现因为接入了 SDK 而导致宿主应用不稳定的情况。
  • 版本稳定更新。SDK 版本迭代非常缓慢,要尽可能对使用者屏蔽迭代过程,避免带来不必要的适配成本。

高效

无论是普通的应用开发还是 SDK 开发,都应该考虑到性能问题,SDK 设计者要着重考虑以下问题:

  • 更少的内存占用。一般 SDK 和 App 运行在同一进程,此时 SDK 要管理好自己占用的内存,合理分配,注意释放。
  • 更少的内存抖动。在占用更少内存的前提下,SDK 设计者必须减少频繁 GC 造成的内存抖动问题。
  • 更少的电量消耗。低电量消耗和高性能表现之间很难做到权衡,可以从 CPU 计算量、屏幕刷新帧率等角度考量。

3. SDK 架构设计

SDK 的架构实现决定了后续的维护难度,所以最好能够结合实际业务确定合适的方案。以项目中的模块化开发为例,讲讲架构设计的原则。

遵循面向对象开发的几大原则,目的是达到三个目标:可维护性、可重用性和可扩展性。具体来讲:

  • 根据单一职责原则,将系统拆分为多个小模块,每个模块保持相对独立,降低实现类的复杂度。
  • 根据接口隔离原则,为每个模块定义契约接口,接口的粒度要小,功能要细,越细小越易维护。
  • 模块之间通过协议或接口通信,避免直接相互依赖,以降低耦合,互相了解最少,体现了迪米特法则。
  • 根据开闭原则,定义各个模块的公共行为,通过模版方法设计模式提供骨架实现,易于功能扩展。
  • 根据组合优于继承的原则,当多个模块功能叠加时,使用类的组合保证设计的灵活性。

比如项目第三方 demo 的功能模块借鉴了 Java 集合框架的架构,分为契约接口、抽象类和具体实现三部分。

  • 首先定义 IEffectModule 作为特效的契约接口,包括创建、设置参数、销毁等各个功能模块的公共操作。
  • AbstractEffectModule 作为 IEffectModule 的骨架实现,实现了共同使用的方法,定义了公共的成员变量。
  • 定义美颜 IFaceBeautyModule 接口,其继承 IEffectModule 接口,包括额外的设置参数操作,FaceBeautyModule 作为其实现类,同时继承 AbstractEffectModule,复用基类的代码。
  • 美妆美体模块类似,先定义契约接口,然后定义具体实现,接口间相互隔离,接口内高度内聚。
  • FURenderer 实现 IFURenderer 渲染接口和 IModuleManager 模块管理接口,组合各个功能模块。
UML类图

4. SDK 设计规范

API 设计在任何开发中都非常重要,许多时候软件的质量好坏体现在 API 的设计上。在普通的应用开发中,API 只会在开发人员间流通,不会暴露给非本应用开发的其他人员。但是 SDK 作为一种服务,需要向开发者暴露一部分 API,这样才能使用 SDK 的服务。

下面列出一些应该重点关注的原则。

  1. 方法名表明其用途

好的方法名最直观表明它的功能,名字是自解释的,不需要额外的文档,这样做会减少不必要的沟通成本。对于开发者而言,还有什么比直接读代码更直观呢?《重构》一书中讲到,要像给自己孩子起名一样给每个变量命名,这个要求不算过分吧。

  1. 参数的合法性检验

如果程序运行时出现异常,会破坏使用者的体验,影响非常不好。我们采用“防御式编程”的思想,能够避免非法输入对系统的破坏性。

当合法性校验不通过时,针对方法权限不同分别对应不同不同的处理策略:

  • 对于公开方法显式检查抛出异常,并使用 @throw 来说明抛出异常的原因
  • 对于私有方法通过断言的方式来检查参数的合法。
  • 检查构造方法的参数的合法性,以使对象处在统一状态。

需要注意的是,如果检查的代价太大,那就需要综合考量。

  1. 方法只实现单一功能

一个方法应该具有单一的功能,尽可能做更少、更专的事情,这也是单一职责原则的体现。“阿里巴巴代码规约”规定一个方法
最好不要超过 80 行,对庞大的方法要拆分成更小的。

另外注意,宁可提供小而美的方法也不要提供大而全的方法,大而全的方法往往经常发生变动,产生风险的可能性更高。因此不如提供更小的方法以便组合使用,小而美的方法更易做到代码复用。

  1. 访问权限控制

包括类方法的权限和变量的权限,能声明私有的不要公开,外部知道得越少越好。能声明静态的方法就用静态,静态方法天然线程安全,体现继承关系的用 protected 修饰,确保公开的方法和变量是安全可靠的。

  1. 避免过长参数

过长的参数会造成记忆上困难,还有调用传参容易出错,应当尽力避免。在无法避免过长参数的情况下,考虑其他的方法进行解决:

  • 通过使用 Builder 模式来实现
  • 通过将多个参数封装成类对象

例如,项目里有个方法,参数非常多。

int onDrawFrameSingleInput(byte[] img, int w, int h, int format, byte[] readBackImg, int readBackW, int readBackH);

重构后,把参数封装成对象,调用方法只用构造一个对象传入,避免大量参数带来不好的体验感。

public class VideoFrame {
    private int width;
    private int height;
    private byte[] data;
    private byte[] readback;
    private int readbackWidth;
    private int readbackHeight;
    private int pixelFormat;
    // ...
}

int onDrawFrameSingleInput(VideoFrame videoFrame);
  1. 慎用方法重载

滥用重载容易让开发者感到疑惑,在需要重载方法的时候,可以使用不同方法名来代替。对于构造函数,可以通过静态工厂来代替重载。

Java 中提供的 ObjectOutputStream 类就是个很好的示范:它的 write 对于每个基本类型都有一个变形,比如写出字符、写出 boolean 等操作。设计者并没有使用重载将其设计成 write(Long l)、write(Boolean b),而是将其设计为 writeLong(l)、writeBoolean(b)。

例如,项目对外的处理方法全部是重载,只能根据参数区分,迷惑性非常大。修改为不同的方法名后,看到名字就知道要调用的方法,清楚了不少。

// 重构前
int onDrawFrame(byte[] img, int tex, int w, int h);
int onDrawFrame(byte[] img, int w, int h);
// 重构后
int onDrawFrameDualInput(byte[] img, int tex, int w, int h);
int onDrawFrameSingleInput(byte[] img, int w, int h, int format);
  1. 避免方法直接返回 null

对于需要返回数组或集合的方法,不要返回null。比如我们去买糕点店买面包,面包没了是一种正常状态,就不应该返回 null,而是返回长度为 0 的数组或集合。Java 提供了 Collections.emptyXXX() 表示空集合。

  1. 避免引入第三方库

GitHub有许多开源的第三方库,比如网络请求 OkHttp、图片加载 Glide 等,但在 SDK 开发中,遵循的基本的原则是:

  • 最小可用性原则,即用最少的代码,如无必要勿增实体。
  • 最少依赖性原则,即用最低限度的外部依赖,如无必要勿增依赖。

引入第三方库可能带来下面几个问题:

  • 宿主应用的第三方库和 SDK 依赖的版本不一致,容易引起冲突,增加对接的成本。
  • 开源库不断更新,SDK 也要及时更新,增加额外的维护工作量。
  • 由于引入开源库,出现问题难以排查。
  1. 保证兼容性

SDK 是不断迭代的,每次发布都会有新功能和 bug 修复。对于使用者来说,升级版本不该有太大的改动,一般直接替换库文件或者修改远程依赖库的版本号就够了。避免直接对公开接口的重命名,如果旧接口废弃,要通过 @Deprecated 关键字标明,并给出替代方案和废弃的时间。

  1. 减少入侵性

要保证较少的代码侵入主要在对外提供服务时,充分考虑开发者的使用场景来设计优良的 API。一套优良的 API 在定义时要满足绝大数开发者预期的方式——语义上要求通俗易懂,使用上要求简单可靠。具体的表现是,在正常情况下能够稳定可靠地运行,在异常情况下及时地反馈错误信息。

比如使用 Gradle 下载依赖库,AAR 包中有不必要的 bundle 资源,我们提供了打包 apk 时的构建配置,自由选择要打包的 bundle,减少了对宿主应用的侵入性。

    applicationVariants.all { variant ->
        variant.mergeAssetsProvider.configure {
            doLast {
                delete(fileTree(dir: outputDir,
                        // 删除不必要的 bundle 文件
                        includes: ['model/ai_face_processor_lite.bundle',
                                   'model/ai_gesture.bundle',
                                   'graphics/controller.bundle',
                                   'graphics/tongue.bundle']))
            }
        }
    }

5. SDK 交付

封装包

Android 平台通常使用 jar 和 aar 发布 SDK,区别是 jar 只包含代码,aar 可以包含代码、资源和动态库。一般而言 aar 是最合适的交付方式,把它上传到 maven 服务器,使用者就可以一行代码集成。对于需要灵活定制的客户,我们也会提供 SDK 的源码,弊端就是升级困难,要改动很多的代码。

对于代码混淆,公开接口和 native 使用的接口不要混,内部的实现细节可以混淆,以减少 SDK 包的大小。

接入文档

接入文档用来告诉 SDK 使用者,如何使用 SDK、详细步使用骤和可能发生的问题。文档内容包括:更新记录、基本信息、API 说明、集成步骤、FAQ等。好文档的标准就是清晰明了,通俗易懂。一个完全不懂 SDK 的开发者看着文档就能对接,对于经常遇到的问题要逐条列出,专业名词要有对应的解释。

Demo 示例

集成 Demo 通常是一个简单的 App,用来展示如何快速地接入 SDK。Demo 的源码托管到 GitHub,方便使用者参考,其版本变更策略和 SDK 版本的变化保持一致。尽管是个 Demo,它的开发原则也要与 SDK 一致,确保高质量的交付。

参考文章: