写在前面
一直以来, 技术圈里面只要涉及 Android Library 的文章, 几乎都在讲如何发布到 Maven/Jcenter, 却很少见到有文章来指导大家如何编写一个规范又好用的 Android Library.
这几年 Android 各式各样的开源库层出不穷, 国内的很多开发者都慷慨地将自己的一些成果做成开源库发布出去, 然而当我们兴致盎然地想去试用一下这些库的时候, 却时常会遇到 "引用"" 依赖 ""冲突""API 调用 " 等各种问题, 这其中有很多问题, 其实是库的作者本身造成的.
魅族的联运 SDK 从去年 8 月份开始立项, 10 月份开始逐渐有合作伙伴开始接入, 经过半年多以来已经有超过 50 家 cp 应用接入, 期间版本仅升级了 1 次, 其余时间一直在稳定运行并寻求新的合作伙伴. 在期间我们也收到了很多 cp 应用开发者的反馈, 但更多的都表示这个库接起来非常轻松易上手, 这也让我非常欣慰.
事实上, 我在正式参加工作之前, 已经做了 2 年多时间的个人开发者, 这段经历让我深刻地体会到了开发者究竟喜欢什么, 不喜欢什么. 如果每一个 Android Library 的作者在编写的时候能够常去换位思考, 多站在接入者的角度审视自己这个库的设计与实现, 那么往往出来的 Android Library 效果都不会差. 所以我会在接下来的内容中跟大家分享一些我们的做法, 这些做法有一些也是踩了坑之后才填上的, 我会把他们写出来, 希望对大家今后的开发工作有所帮助.
规范工程结构
一个规范的 Android Library 工程应该由一个 library 模块与一个 demo 模块共同组成.
demo 模块的好处有两点:
方便开发时自己调试, 自己写的库, 自己写的过程中就要不停尝尝咸淡才能保证 "真香"
库发布后可以编译出 apk 供人先行体验
注意 demo 模块的 build.gradle 在引用 library 时应该做出区分, 如果是 debug 编译模式, 则直接引用 library 项目, 如果是 release 编译模式, 则应该引用你发布的版本. 相信 Android 开发者都有过 "开发调试的时候好好的, 编出来的正式版就有问题" 的经历, 使用这样的引用模式, 万一你发布的库有问题, 则可以在编译 demo apk 的时候立刻发现. 好在 build.gradle 在引用的时候可以很方便做出区分:
- debugImplementation project(':library') //debug 版本直接引用本地项目
- releaseImplementation '远程库地址' //release 版本引用远程版本用来最终测试发现问题
指导接入者快速依赖全部 aar
如果你的库没办法发布到 mavenCentral, 那么提供 SDK 给别人的时候 可能会有多个 aar 需要对方添加到项目里. 我们经常在网上看到一做法, 要求接入者在依赖时, 先把 aar 文件拷贝到项目下, 然后修改 build.gradle 申明参与编译, 接入者必须仔细看 aar 的名字是什么, 因为在 build.gradle 是需要声明清楚的.
事实上, 你的接入者没有义务去弄清你的 aar 命名. 接你的库已经够累了, 为什么还要人家仔细看你的命名呢? 这里推荐一种做法:
让你的接入者在他们项目 App 模块下新建 libs/xxx 目录, 将你们提供的所有 aar 拷贝进去, 这个 XXX 可以是你们渠道的名字, 以后这个下面的 aar 就全是你们的, 跟其它的隔离开.
打开 App 的 build.gradle, 在根节点声明:
- repositories {
- flatDir {
- dirs 'libs/xxx'
- }
- }
3. 在 dependencies{} 闭包内添加如下声明:
- // 递归'libs/xxx` 下所有的 aar 并引用
- def xxxLibs = project.file('libs/xxx')
- xxxLibs.traverse(nameFilter: ~/.*\.aar/) { file ->
- def name = file.getName().replace('.aar', '')
- implementation(name: name, ext: 'aar')
- }
这么一来, gradle 在编译前就会自动进到 xxxLibs 目录下面, 遍历并引用所有 aar 文件. 之后哪个 aar 有更新, 就让你的接入者直接把新的扔到 XXX 目录, 删除老的就行. 至于你的 aar 前缀是啥, 他们根本不用关心.
Kotlin? 大胆用!
Google 早在 2017 年就官宣了 Android 与 Kotlin 的关系. 我在这次写 SDK 的时候最大胆的决定就是全部使用 Kotlin, 事实证明我是正确的. Kotlin 的引入帮我省去了大量的胶水代码, 各种语法糖吃起来也是真香. 所以从现在起如果你决心造一个轮子, 大胆全部使用 Kotlin 来写吧, 但是请注意. 因为你的引用者大部还是 Java 程序员, 甚至可能还不熟悉 Kotlin, 因此一些兼容点还是值得注意的.
引用者的项目必须添加 Kotlin 支持
如果你的库是 Kotlin 编写的, 不管用你库的人是用 Java 调还是 Kotlin, 请他们把项目添加 Kotlin 支持, 否则在编译期间没问题, 但在运行期间很有可能遇到 NoClassDefError, 比如下面这个:
java.lang.NoClassDefFoundError:Failed resolution of: Lkotlin/jvm/internal/Intrinsics
而添加依赖的方法也很简单: 只需要 Android Studio -> Tools -> Kotlin -> Configure Kotlin in project, Android Studio 会自动帮助项目添加依赖插件, Gradle Sync 一下如果没问题, 就搞定了.
伴生对象里需要暴露的 API 请打上 @JvmStatic
已经在写 Kotlin 的小伙伴应该都清楚, Kotlin 的 "静态方法","静态常量" 是靠 "伴生对象" 来实现的. 比如一个简单的类:
- class DemoPlatform private constructor() {
- companion object {
- fun sayHello() {
- //do something
- }
- }
- }
这个类如果我想调 sayHello() 方法, 在 Kotlin 里非常简单, 直接 DemoPlatform.sayHello()就好. 但是如果在 Java 里, 就必须使用编译器自动帮我们生成的 Companion 类, 变成 DemoPlatform.Companion.sayHello(). 这对于不熟悉 Kotlin 的 Java 程序员来说是很不友好的, 尽管 IDE 的提示可能会让他们自己最终摸索出这个方法, 但是面对不熟悉的 Companion 类仍然会一脸懵. 所以最佳的做法是给这个方法打上 @JvmStatic 注解:
- @JvmStatic
- fun sayHello() {
- //do something
- }
这么一来编译器就会为你这个 Kotlin 方法 (Kotlin function) 单独生成一个静态可直接访问的 Java 方法 (Java method), 此时再回到 Java 类里面, 你就可以直接 DemoPlatform.sayHello() 了.
事实上这个方法 Google 自己也在用, 如果你的项目在用 Kotlin, 你可以尝试在代码树上右击 -> New -> Fragment -> Frgment(Blank), 让 Android Studio 自动为我们创建一个 Fragment. 我们都知道一个规范的 Fragment 必须包含一个静态的 newInstance() 方法, 来限制传进来的参数, 可以看到 Android Studio 自动帮我们生成的这个方法上面, 也有一个 @JvmStatic 注解.
- @JvmStatic
- fun newInstance(param1: String, param2: String) =
- BlankFragment().apply {
- arguments = Bundle().apply {
- putString(ARG_PARAM1, param1)
- putString(ARG_PARAM2, param2)
- }
- }
很多项目在迁移阶段肯定是 Java 与 Kotlin 混调的, 而我们作为一个给别人用的 Android Library 就更不用说了, 一个小小的注解可以省下接入者的一些学习成本, 何乐而不为呢?
Proguard 混淆
自我混淆
如果你的库仅仅想供人使用, 而并没有打算完全开源, 请一定记得打开混淆. 在打开之前. 把需要完全暴露给调用者的方法或者属性打上 @Android.support.annotation.Keep 注解就行, 比如上面的 sayHello()方法, 我希望把它暴露出去, 那就变成了:
- @Keep
- @JvmStatic
- fun sayHello() {
- //do something
- }
当然了, 不仅仅是方法, 只要是 @Keep 注解支持的范围都可以. 如果你还不知道 @Keep 注解是咋回事, 兄弟你再不补课就真的要失业了.
而启用混淆的方法也很简单, 在编译 release 版本的时候把混淆启用即可, 就像这样:
- release {
- minifyEnabled true
- proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
- }
这样一来, 调用者依赖了你的库之后, 除了你自己暴露的方法或者类, 一些内部实现就不那么容易找到了.
把自己的 ProGuard 配置文件打包进 aar
我们经常在一些开源库的主页介绍下面看到一段 Proguard 内容, 目的是让调用者把他加到自己 App 模块的 Proguard 配置文件中去. 其实 Android 的编译系统早就支持库模块包含自己的 ProGuard 配置文件了, 如果你希望你自己库里的一些代码, 在调用者编译时也不被混淆, 可以在自己 library 的 proguard-rules.pro 里定义好:
然后打开 library 的 build.gradle, 在 defaultConfig 闭包里调用 consumerProguardFiles() 方法:
- defaultConfig {
- minSdkVersion build_versions.min_sdk
- targetSdkVersion build_versions.target_sdk
- consumerProguardFiles 'proguard-rules.pro'
- ...
- }
加上之后我们可以编译一次 aar, 打开看一下, 会发现里面多了一个 proguard.txt 文件, 一旦你的库被依赖, Gradle 会把这个规则与 App 模块的 Proguard 配置文件 合并后一起运行混淆, 这样一来引用你 library 的人就再也不用担心混淆配置的问题了, 因为你已经完全帮他做好.
so 文件
CMake 直接编译 so 文件
联运 SDK 由于涉及支付业务, 一些安全相关的工作势必要放到 C 层去执行. 在最开始的时候我也考虑过直接编译好 so 文件, 让接入方直接拷贝到 jni 目录下, 事实上国内现在很多第三方库让别人接的时候都是这么做的, 然而这个做法实在是太不酷了, 接入方在操作过程中经常会遇到这几个问题:
so 名字是什么?
拷到哪个目录下面?
build.gradle 怎么配?
abi 怎么区分?
好的是, 从 Android Studio 2.3 开始, CMake 已经被很好地集成了进来, 我们可以在项目里直接添加 C/C++ 的代码, 然后编译期间动态生成 so 文件.
关于项目里集成 C/C++ 编译的方法, 网上已经有很多教程了, 大家 Google 一下 Android Studio Cmake 就会有很多. 当然我最推荐的还是官网教程. 或者如果你跟我一样喜欢动手实践的话, 可以新建一个干净的 Android Project, 然后在向导里勾上 Include C++ Support, 最后生成出来的工程就会包含一个简单的例子, 学习起来非常容易.
- extern "C" JNIEXPORT jstring JNICALL
- Java_your_app_package_name_YourClass_stringFromJNI(
- JNIEnv *env,
- jobject /* this */) {
- std::string hello = "Hello from C++";
- return env->NewStringUTF(hello.c_str());
- }
- class YourClass(private val context: Context) {
- init {
- System.loadLibrary(your-name-lib")
- }
- /**
- * A native method that is implemented by the 'native-lib' native library,
- * which is packaged with this application.
- */
- external fun stringFromJNI(): String //Kotlin 的 external 关键字 类似 Java 的 native 关键字
- }
尽量包含所有 abi, 把选择权交给接入方
在联运 SDK 上线后的一个月, 我们收到 cp 反馈接入了之后有奔溃, 后来检查发现是 armeabi 下没有 so 文件导致的. 这本没有什么问题. 但是你没有办法保证接入方应用的 armeabi 文件里也是空的, 一旦这里面有 so ,Android 就会去这里面找; 还有一种可能就是现在很多应用会设置 abiFilter 去过滤掉一些 abi, 万一人家只想保留 armeabi, 而你的 library 里面又没有, 这两种情况都会导致 crash. 然而从 ndk-16 开始, CMake 已经不会再编译 so 文件到 armeabi 目录下了, 所以为了确保兼容, 我们必须在 library 的 build.gradle 里手动声明自己需要编出哪几个 abi:
- defaultConfig {
- externalNativeBuild {
- cmake {
- cppFlags "" abiFilters'arm64-v8a','armeabi','armeabi-v7a','x86','x86_64'
- }
- }
- }
这么一来你的 library 编出来之后就会包含上面 5 种 abi, 确保所有的新老机型起码都不会崩溃, 如果你的接入方嫌你的 so 太多太大了, 他自己可以在 App 编译期间设置过滤,"反正我都有, 你自己挑吧".
Resource 资源
库内部资源的命名不要干扰接入方
相信大家平时开发过程中都有过类似的经历: 一旦引入了一些第三方库, 自己写代码的时候, 想调用某个资源文件, 一按提示, IDE 提示的全是这些第三方库里面的资源, 而自己 App 里面的资源却要找半天.
我们平时写库的时候难免会自己定义一些 Resource 文件, 包括 string.xml xxx_layout.xml color.xml 等等, 这些库生成的 R.java 一旦参与 App 的编译之后, 是可以直接被引用到的, 所以自然而言也会被 IDE 索引进提示里面. 而照常来讲, 一个应用是不应该直接引用一些第三方库里面的资源的, 搞不好就很容易出现一些问题. 比如万一哪天人家库升级把这串值改掉了, 或者干脆拿掉了, 你 App 就跪了.
联运 SDK 在开发的时候就注意到了这一点, 比如我们的 SDK 叫 MeizuLibrarySdk, 那么我在定义 strings.xml 时, 我会写:
- <string name="mls_hello">你好</string>
- <string name="mls_world">世界</string>
再比如, 我需要定义一个颜色, 我会在 colors.xml 里面写:
<color name="mls_blue">#8124F6</color>
相信大家应该已经发现了, 每一个资源都会以 mls 开头, 这样有个好处, 就是别人在引用了你的库之后, 用代码提示的时候, 只要看到 mls 开头的资源, 就知道是你库里面的, 不要用. 但是这还不够, 因为 Android Studio 还是会在人家写代码的时候把你的资源提示出来:
有没有一种办法, 来让 library 开发者可以向 Android Studio 申明自己需要暴露哪些资源, 而哪些不希望暴露呢?
当然是有的. 我们可以在 library 的 res/values 下面建立一个 public.xml 文件:
- <!-- 向 Android Studio 声明我只希望暴露这个名称的 string 资源 -->
- <public name="mls_hello" type="string" />
这样依赖, 如果你在 App 里面试图引用 mls_world,Android Studio 就会警告你引用了一个 private 资源.
这个方法的详细介绍可以看官方文档:
但是不知道为什么, 这个方法我在 15,16 年的时候还是有效的. 但是升级到 Android Studio 3.3 + Gradle Plugin 3.1.3 之后我发现 IDE 不会再警告了, 也可以通过编译, 不知道这又是什么坑. 但官方文档依旧没有去掉关于这个用法的描述, 估计是插件的一个 bug 吧.
第三方依赖库
JCenter() 能引用到的, 不要打包进你自己里面
本着 "不要重复造轮子" 的原则, 我们在开发第三方库的时候, 自身难免也会依赖一些第三方库. 比如用于解析 JSON 的 Gson, 或者用于加载图片的 Picasso. 这些库本身都是 jar 文件的, 所以之前会有一些第三方库的作者在用到这些库的时候, 把对应的 jar 下载到 libs 下面参与编译, 最终编译到自己的 jar 或者 aar 里面. 而接入者的项目原可能已经依赖了这些库, 一旦再接入了你的, 就会导致错误, 提示 duplicated class was found.
这种做法与 Gradle 的依赖管理机制完全是背道而驰的. 正确的原则应该是:
只要第三方应用自己能从 JCenter/MavenCentral 获取到的库, 如果你的库也依赖了, 请一概使用 compileOnly
举个例子, 比如我的库里面需要发起网络请求, 按照 Google 的推荐, 目前最好用的库应该是 Retrofit 了, 这个时候我应该在 library 的 build.gradle 里这样写:
compileOnly "com.squareup.retrofit2:retrofit:2.4.0"
compileOnly 标明后面的库只会在编译时有效, 但不会你 library 的打包. 这么一来, 你只需要告诉你的引用者, 让他们在自己 App 模块的 build.gradle 里加上引用即可, 就像这样:
implementation "com.squareup.retrofit2:retrofit:$versions.retrofit"
这样做的好处是, 如果引用者的项目本来就已经依赖了 Retrofit, 那么皆大欢喜, 什么都不用加, 并且上面的 $versions.retrofit 意味着引用者可以自己决定他要用哪个版本的 Retrofit, 一般来讲只要大于等于你编译库时用的版本都不会有太大问题, 除非 Retrofit 自己大量修改了 API 导致编不过的那种. 这么一来就再一次把选择权交给了你的引用者, 既不用担心冲突, 也不用担心版本跟你用的不匹配.
使用单个文件统一依赖库的版本
如果你的项目分了好多模块, 结构比较复杂, 我这边推荐大家使用一个 versions.gradle 文件来统一所有模块依赖库的版本. 这一招并不是我原创的, 而是 Google 在 architecture-components 的官方 demo 里体现的. 这个 demo 的 Project 包含了大量的 module, 有 library 有 App, 而所有的 module 都需要统一版本的依赖库, 拿 buildToolsVersion 为例, 总不能不能你依赖 27.1.1, 我依赖 28.0.0 这样. 我把链接放在下面, 推荐大家都去学习一下这个文件的写法, 以及它是如何去统一所有 module 的.
API 设计
关于 API 设计, 由于大家的库所要实现的功能不一样, 所以没有办法具体列举, 但是依然在这里为大家分享一些注意点, 其实这些注意点只要能站在接入者的角度去考虑, 大多数都能想到, 而问题就在于你在写库的时候愿不愿意去为你的接入者多考虑一点.
不要在人家的 Application 类里蹦迪
相信暴露一个 init() 方法让你的调用者在 Application 类里做初始化, 是很多库作者喜欢干的事. 然而大家反过来想一下, 我们都看过很多性能优化的文章, 通常第一篇都是让大家检查一下自己的 Application 类, 有没有做太多耗时的操作? 因为 Application 是你应用起来之后第一个要走的, 如果你在里面做了耗时操作了, 势必会推迟 Activity 的加载, 然而这一点却很容易被大家忽略. 所以如果你是一个库的作者, 请:
不要在你的 init() 方法里做任何耗时操作
更不要提供一个 init() 方法, 让人家放在 Application 类里, 还让人家 "最好建议异步", 这跟耍流氓没区别
统一入口, 用一个平台类去包含所有的功能
这里的平台类是我自己取的名字, 你可以叫 XXXManager,XXXProxy,XXXService,XXXPlatform 都可以, 把它设计成单例, 或者把内部所有的方法写成静态方法. 不要让你的调用者费劲心思去找应该实例化哪个类, 反正所有的方法都在这一个类里面, 拿到实例之后调用对应的方法即可. 这样统一入口, 既降低了维护成本, 你的调用者也会感谢你.
所有的常量, 定义到一个类
- if (code == 10012) {
- //do something
- }
这个 10012 是什么? 是你库里面定义的返回码? 那为啥不写成常量暴露给你的调用者呢?
- @Keep
- class DemoResult private constructor(){
- @Keep
- companion object {
- /**
- * 支付失败, 原因: 无法连接网络, 请检查网络设置
- */
- const val CODE_ERROR_CONFIG_ERROR: Int = 10012
- const val MSG_ERROR_CONFIG_ERROR: String = "配置错误, 请检查参数"
- ...
- }
- }
这样一写, 你的调用者只要点点鼠标, 进来看一下你这个类, 就能迅速把错误码跟错误提示对应上. 懒一点的话, 他们甚至可以直接用你定义的这些提示去展现给用户. 而且万一有一天, 服务端的同事告诉你, 10012 需要变成别的值, 此时你只需要修改你自己的代码就行, 对库的接入者而言, 它依然是 DemoResult.CODE_ERROR_CONFIG_ERROR , 不需要做任何修改, 这样方便接入者的事何乐而不为呢?
帮助接入者检查传入参数的合法性
如果你的 API 对传入的参数有要求. 建议在方法执行的第一步就对参数予以检查. 一旦调用者传递的参数不合法, 直接抛异常. 有很多开发者觉得抛异常这种行为不能接受, 因为毕竟这在 Android 平台的直接表现就是 App crash. 但是于其让 App 在用户手里 crash, 还不如直接在开发阶段 crash 掉让开发者立刻注意到并且予以修复.
这里以 String 的判空为例, 如果你用 Kotlin 来开发, 一切都简单多了. 比如我现在有一个实体如下:
data class StudentInfo(val name: String)
一个 StudentInfo 是必须要有一个 name 的, 并且我声明了 name 是不为空的. 这个时候如果你在 Kotlin 里面实例化 Student 并且 name 传空, 是直接编译不过的. 而对于 Java 而言, Kotlin 帮我们生成的 class 文件也已经做好了这一点:
- public PayInfo(@NotNull String var1) {
- Intrinsics.checkParameterIsNotNull(var1, "name");
- super();
- this.name = var1;
- }
继续看 checkParameterIsNotNull() 方法:
- public static void checkParameterIsNotNull(Object value, String paramName) {
- if (value == null) {
- throwParameterIsNullException(paramName);
- }
- }
throwParameterIsNullException()就是一个比较简单的抛异常了.
- private static void throwParameterIsNullException(String paramName) {
- StackTraceElement[] stackTraceElements = Thread.currentThread().getStackTrace();
- // #0 Thread.getStackTrace()
- // #1 Intrinsics.throwParameterIsNullException
- // #2 Intrinsics.checkParameterIsNotNull
- // #3 our caller
- StackTraceElement caller = stackTraceElements[3];
- String className = caller.getClassName();
- String methodName = caller.getMethodName();
- IllegalArgumentException exception =
- new IllegalArgumentException("Parameter specified as non-null is null:" +
- "method" + className + "." + methodName +
- ", parameter" + paramName);
- throw sanitizeStackTrace(exception);
- }
所以如果你用的是 Java, 试图直接 Student student = new Student(), 运行时也是会直接 crash 掉并且告诉你 name 不能为空的. 联运 SDK 有大量的参数检查用了 Kotlin 的这一特性, 使得我少些了很多代码, 由编译器自动帮我注入检查代码.
这里要推荐大家参考一下 Android.support.v4.util.Preconditions , 这个里面封装好了大量的数据类型的情景检查, 源码一看就明白. 希望大家在写一个库的时候, 都能做好传入参数合法性的检查工作, 把问题发现在开发阶段, 也能确保运行阶段不被意外值搞到奔溃.
一些遗憾
到这里, 我基本上已经把这次 SDK 开发过程中的经验与踩过的坑都分享给大家了. 当然了, 这个世界上没有完美的事物, 目前我们的联运 SDK 仍然有许多方面的不足, 比如:
没有发布到 mavenCentral(), 需要开发者手动下载 aar 并添加进编译
SDK 需要依赖 Picasso 来完成图片加载, 这部分功能应该抽象出来, 由接入方去用他们自己的方案实现
我们的 SDK 总共由 7 个 aar 组成, 每个 aar 背后都有一个小团队来专门维护, 开发者接入时需要全部复制到一个目录下, 有些冗余跟臃肿
这些不足有些是因为项目初期没有考虑充分导致, 有些是受限于项目架构上的原因导致的. 接下来我们会逐一评估, 争取把我们的 SDK 越做越好. 同时也欢迎大家在评论区亮出自己在写 Android Library 时踩过的坑或者分享一些技巧, 我会在后面逐步把它更新到文章里来, 大家一起努力, 造出更多规范的, 优秀的轮子.
来源: https://juejin.im/post/5c9228e7f265da60fe7c2732