編寫 Android Library 的最佳實踐

Mr_萬能膠發表於2019-03-22

寫在前面

一直以來,技術圈裡面只要涉及 Android Library 的文章,幾乎都在講如何釋出到 Maven/Jcenter,卻很少見到有文章來指導大家如何編寫一個規範又好用的 Android Library。

這幾年 Android 各式各樣的開源庫層出不窮,國內的很多開發者都慷慨地將自己的一些成果做成開源庫釋出出去,然而當我們興致盎然地想去試用一下這些庫的時候,卻時常會遇到“引用”“依賴”“衝突”“API 呼叫”等各種問題,這其中有很多問題,其實是庫的作者本身造成的。

魅族的聯運 SDK 從去年8月份開始立項,10月份開始逐漸有合作伙伴開始接入,經過半年多以來已經有超過50家 cp 應用接入,期間版本僅升級了1次,其餘時間一直在穩定執行並尋求新的合作伙伴。在期間我們也收到了很多 cp 應用開發者的反饋,但更多的都表示這個庫接起來非常輕鬆易上手,這也讓我非常欣慰。

編寫 Android Library 的最佳實踐

事實上,我在正式參加工作之前,已經做了2年多時間的個人開發者,這段經歷讓我深刻地體會到了開發者究竟喜歡什麼,不喜歡什麼。如果每一個 Android Library 的作者在編寫的時候能夠常去換位思考,多站在接入者的角度審視自己這個庫的設計與實現,那麼往往出來的 Android Library 效果都不會差。所以我會在接下來的內容中跟大家分享一些我們的做法,這些做法有一些也是踩了坑之後才填上的,我會把他們寫出來,希望對大家今後的開發工作有所幫助。

規範工程結構

一個規範的 Android Library 工程應該由一個 library模組與一個demo模組共同組成。

編寫 Android Library 的最佳實踐

demo模組的好處有兩點:

  1. 方便開發時自己除錯,自己寫的庫,自己寫的過程中就要不停嚐嚐鹹淡才能保證“真香”
  2. 庫釋出後可以編譯出 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 命名。接你的庫已經夠累了,為什麼還要人家仔細看你的命名呢?這裡推薦一種做法:

  1. 讓你的接入者在他們專案 app 模組下新建 libs/xxx 目錄,將你們提供的所有 aar拷貝進去,這個 XXX 可以是你們渠道的名字,以後這個下面的 aar 就全是你們的,跟其它的隔離開。
  2. 開啟 appbuild.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裡定義好:

編寫 Android Library 的最佳實踐

然後開啟 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 的人就再也不用擔心混淆配置的問題了,因為你已經完全幫他做好。

編寫 Android Library 的最佳實踐

so 檔案

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,最後生成出來的工程就會包含一個簡單的例子,學習起來非常容易。

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 還是會在人家寫程式碼的時候把你的資源提示出來:

編寫 Android Library 的最佳實踐

有沒有一種辦法,來讓 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 的載入,然而這一點卻很容易被大家忽略。所以如果你是一個庫的作者,請:

  1. 不要在你的 init() 方法裡做任何耗時操作
  2. 更不要提供一個 init() 方法,讓人家放在 Application 類裡,還讓人家“最好建議非同步”,這跟耍流氓沒區別

統一入口,用一個平臺類去包含所有的功能

這裡的平臺類是我自己取的名字,你可以叫 XXXManagerXXXProxyXXXServiceXXXPlatform都可以,把它設計成單例,或者把內部所有的方法寫成靜態方法。不要讓你的呼叫者費勁心思去找應該例項化哪個類,反正所有的方法都在這一個類裡面,拿到例項之後呼叫對應的方法即可。這樣統一入口,既降低了維護成本,你的呼叫者也會感謝你。

所有的常量,定義到一個類

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 時踩過的坑或者分享一些技巧,我會在後面逐步把它更新到文章裡來,大家一起努力,造出更多規範的、優秀的輪子。

相關文章