如何造好輪子?編寫 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 / 連接:點擊連接加入【安卓開發架構】

相關文章
相關標籤/搜索