寫在前面
一直以來,技術圈裡面只要涉及 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')
}
複製程式碼
或者,我們可以參考依賴的第一行,直接用下面的程式碼一步到位(感謝評論區 @那時年少
):
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 的“靜態方法”、“靜態常量”是靠“伴生物件”來實現的。比如一個簡單的類:
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 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 還是會在人家寫程式碼的時候把你的資源提示出來:
有沒有一種辦法,來讓 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 的。
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 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 時踩過的坑或者分享一些技巧,我會在後面逐步把它更新到文章裡來,大家一起努力,造出更多規範的、優秀的輪子。