如何造好轮子?编写 Android Library 的最佳实践

1写在前面

一直以来,技术圈里面只要涉及 Android Library 的文章,几乎都在讲如何发布到 Maven/Jcenter,却不多见到有文章来指导你们如何编写一个规范又好用的 Android Library。html

这几年 Android 各式各样的开源库层出不穷,国内的不少开发者都慷慨地将本身的一些成果作成开源库发布出去,然而当咱们兴致盎然地想去试用一下这些库的时候,却时常会遇到“引用”“依赖”“冲突”“API 调用”等各类问题,这其中有不少问题,实际上是库的做者自己形成的。java

魅族的联运 SDK 从去年8月份开始立项,10月份开始逐渐有合做伙伴开始接入,通过半年多以来已经有超过50家 cp 应用接入,期间版本仅升级了1次,其他时间一直在稳定运行并寻求新的合做伙伴。在期间咱们也收到了不少 cp 应用开发者的反馈,但更多的都表示这个库接起来很是轻松易上手,这也让我很是欣慰。android

事实上,我在正式参加工做以前,已经作了2年多时间的我的开发者,这段经历让我深入地体会到了开发者究竟喜欢什么,不喜欢什么。若是每个 Android Library 的做者在编写的时候可以常去换位思考,多站在接入者的角度审视本身这个库的设计与实现,那么每每出来的 Android Library 效果都不会差。git

因此我会在接下来的内容中跟你们分享一些咱们的作法,这些作法有一些也是踩了坑以后才填上的,我会把他们写出来,但愿对你们从此的开发工做有所帮助。程序员

2规范工程结构

一个规范的 Android Library 工程应该由一个 library模块与一个demo模块共同组成。github

demo模块的好处有两点:面试

  1. 方便开发时本身调试,本身写的库,本身写的过程当中就要不停尝尝咸淡才能保证“真香”
  2. 库发布后能够编译出 apk 供人先行体验

注意 demo 模块的 build.gradle 在引用 library 时应该作出区分,若是是 debug编译模式,则直接引用 library 项目,若是是 release编译模式,则应该引用你发布的版本。json

相信 android 开发者都有过“开发调试的时候好好的,编出来的正式版就有问题”的经历,使用这样的引用模式,万一你发布的库有问题,则能够在编译 demo apk 的时候马上发现。好在 build.gradle 在引用的时候能够很方便作出区分:api

debugImplementation project(':library') //debug 版本直接引用本地项目
releaseImplementation '远程库地址'   //release 版本引用远程版本用来最终测试发现问题

3指导接入者快速依赖所有 aar

若是你的库没办法发布到 mavenCentral,那么提供 SDK 给别人的时候 可能会有多个 aar 须要对方添加到项目里。咱们常常在网上看到一作法,要求接入者在依赖时,先把 aar 文件拷贝到项目下,而后修改 build.gradle 申明参与编译,接入者必须仔细看 aar 的名字是什么,由于在 build.gradle 是须要声明清楚的。

事实上,你的接入者没有义务去弄清你的 aar 命名。接你的库已经够累了,为何还要人家仔细看你的命名呢?这里推荐一种作法:安全

1. 让你的接入者在他们项目 app 模块下新建 libs/xxx 目录,将大家提供的全部 aar拷贝进去,这个 XXX 能够是大家渠道的名字,之后这个下面的 aar 就全是大家的,跟其它的隔离开。

2. 打开 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')
}

或者,咱们能够参考依赖的第一行,直接用下面的代码一步到位(感谢评论区 @那时年少):

implementation fileTree(include: ['*.aar'], dir: 'libs/xxx')

这么一来,gradle 在编译前就会自动进到 xxx 目录下面,遍历并引用全部 aar 文件。以后哪一个 aar 有更新,就让你的接入者直接把新的扔到 XXX 目录,删除老的就行。至于你的 aar前缀是啥,他们根本不用关心。

4Kotlin?大胆用!

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 就更不用说了,一个小小的注解能够省下接入者的一些学习成本,何乐而不为呢?

5Proguard 混淆

自我混淆

若是你的库仅仅想供人使用,而并无打算彻底开源,请必定记得打开混淆。在打开以前。把须要彻底暴露给调用者的方法或者属性打上@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 的人就不再用担忧混淆配置的问题了,由于你已经彻底帮他作好。

6So 文件

CMake 直接编译 so 文件

联运 SDK 因为涉及支付业务,一些安全相关的工做势必要放到 C 层去执行。在最开始的时候我也考虑过直接编译好 so 文件,让接入方直接拷贝到 jni 目录下,事实上国内如今不少第三方库让别人接的时候都是这么作的,然而这个作法实在是太不酷了,接入方在操做过程当中常常会遇到这几个问题:

  1. so  名字是什么?
  2. 拷到哪一个目录下面?
  3. build.gradle怎么配?
  4. abi 怎么区分?

好的是,从 Android Studio 2.3 开始,CMake 已经被很好地集成了进来,咱们能够在项目里直接添加 C/C++ 的代码,而后编译期间动态生成 so 文件。

关于项目里集成 C/C++ 编译的方法,网上已经有不少教程了,你们 Google 一下 Android Studio Cmake 就会有不少。固然我最推荐的仍是官网教程。或者若是你跟我同样喜欢动手实践的话,能够新建一个干净的 Android Project,而后在向导里勾上 Include C++ Support,最后生成出来的工程就会包含一个简单的例子,学习起来很是容易。

https://developer.android.com...

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  r16b 已经弃用了 armeabi ,r17c 直接移除了对 armeabi 的支持,  若是有生成 armeabi 的需求只能下降 ndk 版本。(感谢评论区 @我啥时候说啦jj整理指出)

https://developer.android.com...

因此为了确保兼容,咱们必须在 library 的 build.gradle里手动声明本身须要编出哪几个 abi:

defaultConfig {
    externalNativeBuild {
        cmake {
            cppFlags ""
            abiFilters 'arm64-v8a', 'armeabi', 'armeabi-v7a', 'x86', 'x86_64'
        }
    }
}

这么一来你的 library 编出来以后就会包含上面 5 种 abi,确保全部的新老机型起码都不会崩溃,若是你的接入方嫌你的 so 太多太大了,他本身能够在 app编译期间设置过滤,“反正我都有,你本身挑吧”。

7Resource 资源

库内部资源的命名不要干扰接入方

相信你们平时开发过程当中都有过相似的经历:一旦引入了一些第三方库,本身写代码的时候,想调用某个资源文件,一按提示,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 资源。

这个方法的详细介绍能够看官方文档:

https://developer.android.com...

可是不知道为何,这个方法我在1五、16年的时候仍是有效的。可是升级到 Android Studio 3.3 + Gradle Plugin 3.1.3 以后我发现 IDE 不会再警告了,也能够经过编译,不知道这又是什么坑。但官方文档依旧没有去掉关于这个用法的描述,估计是插件的一个 bug 吧。

8第三方依赖

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 的。

https://github.com/googlesamp...

9API设计

关于 API 设计,因为你们的库所要实现的功能不同,因此没有办法具体列举,可是依然在这里为你们分享一些注意点,其实这些注意点只要能站在接入者的角度去考虑,大多数都能想到,而问题就在于你在写库的时候愿不肯意去为你的接入者多考虑一点。

不要在人家的 Application 类里蹦迪

相信暴露一个 init() 方法让你的调用者在 Application 类里作初始化,是不少库做者喜欢干的事。然而你们反过来想一下,咱们都看过不少性能优化的文章,一般第一篇都是让你们检查一下本身的 Application 类,有没有作太多耗时的操做?

由于  Application 是你应用起来以后第一个要走的,若是你在里面作了耗时操做了,势必会推迟 Activity 的加载,然而这一点却很容易被你们忽略。因此若是你是一个库的做者,请:

  1. 不要在你的 init() 方法里作任何耗时操做
  2. 更不要提供一个 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 StudentInfo(@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(null),运行时也是会直接 crash 掉而且告诉你 name 不能为空的。联运 SDK 有大量的参数检查用了 Kotlin 的这一特性,使得我少些了不少代码,编译器编译后会自动帮我生成。

这里要推荐你们参考一下 android.support.v4.util.Preconditions ,这个里面封装好了大量的数据类型的情景检查,源码一看就明白。但愿你们在写一个库的时候,都能作好传入参数合法性的检查工做,把问题发如今开发阶段,也能确保运行阶段不被意外值搞到奔溃。

一些遗憾

到这里,我基本上已经把此次 SDK 开发过程当中的经验与踩过的坑都分享给你们了。固然了,这个世界上没有完美的事物,目前咱们的联运 SDK 仍然有许多方面的不足,好比:

  1. 没有发布到 mavenCentral(),须要开发者手动下载 aar 并添加进编译
  2. SDK 须要依赖 Picasso 来完成图片加载,这部分功能应该抽象出来,由接入方去用他们本身的方案实现
  3. 咱们的 SDK 总共由 7 个 aar 组成,每一个 aar 背后都有一个小团队来专门维护,开发者接入时须要所有复制到一个目录下,有些冗余跟臃肿

这些不足有些是由于项目初期没有考虑充分致使,有些是受限于项目架构上的缘由致使的。接下来咱们会逐一评估,争取把咱们的 SDK 越作越好。同时也欢迎你们在评论区亮出本身在写 Android Library 时踩过的坑或者分享一些技巧,我会在后面逐步把它更新到文章里来,你们一块儿努力,造出更多规范的、优秀的轮子。

免费获取安卓开发架构的资料(包括Fultter、高级UI、性能优化、架构师课程、 NDK、Kotlin、混合式开发(ReactNative+Weex)和一线互联网公司关于android面试的题目汇总能够加:936332305 / 连接:点击连接加入【安卓开发架构】

相关文章
相关标签/搜索