写在前面
一直以来,技术圈里面只要涉及 Android Library 的文章,几乎都在讲如何发布到 Maven/Jcenter,却很少见到有文章来指导大家如何编写一个规范又好用的 Android Library。
这几年 Android 各式各样的开源库层出不穷,国内的很多开发者都慷慨地将自己的一些成果做成开源库发布出去,然而当我们兴致盎然地想去试用一下这些库的时候,却时常会遇到“引用”“依赖”“冲突”“API 调用”等各种问题,这其中有很多问题,其实是库的作者本身造成的。
魅族的联运 SDK 从去年8月份开始立项,10月份开始逐渐有合作伙伴开始接入,经过半年多以来已经有超过50家 cp 应用接入,期间版本仅升级了1次,其余时间一直在稳定运行并寻求新的合作伙伴。在期间我们也收到了很多 cp 应用开发者的反馈,但更多的都表示这个库接起来非常轻松易上手,这也让我非常欣慰。
image.png
事实上,我在正式参加工作之前,已经做了2年多时间的个人开发者,这段经历让我深刻地体会到了开发者究竟喜欢什么,不喜欢什么。如果每一个 Android Library 的作者在编写的时候能够常去换位思考,多站在接入者的角度审视自己这个库的设计与实现,那么往往出来的 Android Library 效果都不会差。所以我会在接下来的内容中跟大家分享一些我们的做法,这些做法有一些也是踩了坑之后才填上的,我会把他们写出来,希望对大家今后的开发工作有所帮助。
规范工程结构
一个规范的 Android Library 工程应该由一个 library
模块与一个demo
模块共同组成。
image.png
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')
}
或者,我们可以参考依赖的第一行,直接用下面的代码一步到位(感谢评论区 @那时年少
):
implementation fileTree(include: ['*.aar'], dir: 'libs/xxx')
这么一来,gradle 在编译前就会自动进到 xxx
目录下面,遍历并引用所有 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 的“静态方法”、“静态常量”是靠“伴生对象”来实现的。比如一个简单的类:
代码语言:javascript复制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 版本的时候把混淆启用即可,就像这样:
代码语言:javascript复制release {
minifyEnabled true
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
这样一来,调用者依赖了你的库之后,除了你自己暴露的方法或者类,一些内部实现就不那么容易找到了。
把自己的 ProGuard 配置文件打包进 aar
我们经常在一些开源库的主页介绍下面看到一段 Proguard
内容,目的是让调用者把他加到自己 app
模块的 Proguard 配置文件
中去。其实 Android 的编译系统早就支持库模块包含自己的 ProGuard 配置文件
了,如果你希望你自己库里的一些代码,在调用者编译时也不被混淆,可以在自己 library 的 proguard-rules.pro
里定义好:
image.png
然后打开 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 的人就再也不用担心混淆配置的问题了,因为你已经完全帮他做好。
image.png
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());
}
代码语言:javascript复制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
整理指出)
所以为了确保兼容,我们必须在 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 还是会在人家写代码的时候把你的资源提示出来:
image.png
有没有一种办法,来让 library 开发者可以向 Android Studio 申明自己需要暴露哪些资源,而哪些不希望暴露呢?
当然是有的。我们可以在 library 的 res/values
下面建立一个 public.xml
文件:
<!--向 Android Studio 声明我只希望暴露这个名称的 string 资源-->
<public name="mls_hello" type="string" />
这样依赖,如果你在 app 里面试图引用 mls_world
,Android Studio 就会警告你引用了一个 private 资源。
这个方法的详细介绍可以看官方文档:
developer.android.com/studio/proj…
但是不知道为什么,这个方法我在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 的。
github.com/googlesampl…
API 设计
关于 API 设计,由于大家的库所要实现的功能不一样,所以没有办法具体列举,但是依然在这里为大家分享一些注意点,其实这些注意点只要能站在接入者的角度去考虑,大多数都能想到,而问题就在于你在写库的时候愿不愿意去为你的接入者多考虑一点。
不要在人家的 Application
类里蹦迪
相信暴露一个 init()
方法让你的调用者在 Application
类里做初始化,是很多库作者喜欢干的事。然而大家反过来想一下,我们都看过很多性能优化的文章,通常第一篇都是让大家检查一下自己的 Application
类,有没有做太多耗时的操作?因为 Application
是你应用起来之后第一个要走的,如果你在里面做了耗时操作了,势必会推迟 Activity 的加载,然而这一点却很容易被大家忽略。所以如果你是一个库的作者,请:
- 不要在你的
init()
方法里做任何耗时操作 - 更不要提供一个
init()
方法,让人家放在Application
类里,还让人家“最好建议异步”,这跟耍流氓没区别
统一入口,用一个平台类
去包含所有的功能
这里的平台类
是我自己取的名字,你可以叫 XXXManager
、XXXProxy
、XXXService
、XXXPlatform
都可以,把它设计成单例,或者把内部所有的方法写成静态方法。不要让你的调用者费劲心思去找应该实例化哪个类,反正所有的方法都在这一个类里面,拿到实例之后调用对应的方法即可。这样统一入口,既降低了维护成本,你的调用者也会感谢你。
所有的常量,定义到一个类
代码语言:javascript复制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 仍然有许多方面的不足,比如:
- 没有发布到
mavenCentral()
,需要开发者手动下载aar
并添加进编译 - SDK 需要依赖
Picasso
来完成图片加载,这部分功能应该抽象出来,由接入方去用他们自己的方案实现 - 我们的 SDK 总共由 7 个
aar
组成,每个aar
背后都有一个小团队来专门维护,开发者接入时需要全部复制到一个目录下,有些冗余跟臃肿
这些不足有些是因为项目初期没有考虑充分导致,有些是受限于项目架构上的原因导致的。接下来我们会逐一评估,争取把我们的 SDK 越做越好。同时也欢迎大家在评论区亮出自己在写 Android Library 时踩过的坑或者分享一些技巧,我会在后面逐步把它更新到文章里来,大家一起努力,造出更多规范的、优秀的轮子。