前言
API層就是網路層,是一個App必不可少的模組。我從12年開始做安卓開發,從這些年的開發經驗中對API層的實踐進行一些總結,內容方面主要是圍繞HttpClient的選擇,響應處理的程式設計模型和通知UI資料更新的最佳方式。
以下內容僅僅是個人觀點,與實際內容如有出入,煩請指出;若噴,請輕點。
SDK中的Http Client
標題中的Http Client是一個泛指,可能與某個http請求庫重名,它泛指所有的http請求客戶端。
SDK中的client有2個:HttpURLConnection
和Apache的HttpClient
庫。
在最早的時候(大概Android1.x開始),SDK把Java的HttpURLConnection
照搬過來。但是HttpURLConnection
很底層,用起來非常麻煩。你發一個Get請求還要操作流,沒有20行程式碼下不來,上傳檔案要自己拼multi-part
塊,而且這個類在Android2.2之前還有記憶體洩漏的Bug。
估計谷歌自己也不想用,就將Apache的HttpClient
庫內建到SDK中了。在易用性上確實簡潔不少,也實現了像marti-part
這種編碼,不用我們手動拼了。但是缺點是太物件導向了,程式碼比較臃腫。傳送Post請求,再加點Header,就要建立很多的物件,程式碼量依然下不來。於是當時誕生了很多針對HttpClient進行封裝的類庫,我用的最多的就是android-async-http
和xutil
。Android5.0之後,SDK將Apache的HttpClient
移除了。
當然也有針對HttpURLConnection
進行封裝的類庫,比如谷歌自家的Volley
。Volley的效能優秀,且內建圖片載入功能。當時風光過一陣,直到現在我仍然能看到有許多三方庫http使用Volley來做。Volley的缺點是部分Http功能不完善,比如預設不能傳送Post請求,需要手寫一些程式碼;不支援重定向。
現代化的Http Client
Http Client的話題還沒有說完,上面說到谷歌在2013年的IO大會上推了自家的Volley;但是會議上出現了一個小插曲:
當谷歌的開發者在介紹Volley的時候,下面的某個聽眾喊道:
"I prefer OkHttp。"
當時引得眾人大笑,介紹的人員值得很無奈的回了一句:"Yeah, I like OkHttp too."
然後OkHttp就火了,好像Volley的介紹是為了讓人們知道OkHttp。
為什麼OkHttp火?
- 它功能完善:Http編碼,協議和Http Verb的完全支援,Http Cache的完美支援
- 它效能優越:它既沒有基於
HttpURLConnection
,也沒有基於HttpClient
;自己用socket重新實現了一套。內建連線池,會重用連線,會選擇最佳的Host,讓網路延時降到最低 - 它竟然支援攔截器這種現代化的網路功能
- 它API簡潔
OkHttp是目前Android和Java平臺最優秀的Http Client,沒有之一。同時也誕生了基於OkHttp進行封裝的三方庫,比如:Okhttputils
和OkGo
,它們使用起來都非常簡單。
如果你喜歡註解,可以試試同一個團隊出品的Retrofit
。
順便普及一下人員資訊:
-
Square公司:美國的一家做支付的公司,Okhttp和Retrofit的出品團隊,團隊有個大牛叫
JakeWharton
。 -
JakeWharton: Android界的頂尖大牛,現在去了谷歌,在做Kotlin方面的工作。很多人知道他寫了ButterKnife,OkHttp,Retrofit,但是可能不知道當年谷歌團隊的
support-v4
包還沒有支援屬性動畫的時候,人人都用他的NineOldAndroid
類庫來做屬性動畫;當年谷歌團隊的support-v7
包還沒有出現的時候,人人都用它的ActionBarSherlock
來做ActionBar。真正的是一個人撐起一片天。
響應處理的程式設計模型
在Client的選擇上,OkHttp是最佳選擇。但是在響應處理的程式設計模型上,目前所有的Client都提供了Callback的模型來處理響應,用虛擬碼表示就是:
XXClient client = new XXClient();
client.url("https://github.com/li-xiaojun")
.header("a", "b")
.params("c", "d")
.post(new HttpCallback<Bean>(){
public void onError(IOException e){
//do something
}
public void onSuccess(Bean bean){
//do something
}
});
複製程式碼
回撥的模型在程式碼複雜的時候回陷入Callback Hell
的問題,當然你可以用抽取方法來重構,也可以用RxJava來打平回撥的層級;但在可讀性方面仍然沒有同步的程式碼看上去漂亮。來看一個同步模型的程式碼:
Bean bean = client.url("https://github.com/li-xiaojun")
.header("a", "b")
.params("c", "d")
.<Bean>post(); //非同步請求
Result bean = process(baen);
saveDB(bean);//非同步操作
複製程式碼
顯然同步模型會更具可讀性,哪怕你非同步邏輯再複雜,可讀性都不會減少一點。如何能讓同步的程式碼傳送非同步的請求呢?
Java可以用Future來實現,更優雅的是Kotlin的協程。使用Kotlin協程的程式碼看起來像這樣:
GlobalScope.launch {
Bean bean = client.url("https://github.com/li-xiaojun")
.header("a", "b")
.params("c", "d")
.<Bean>post().await(); //非同步請求
Result bean = process(baen);//非非同步
saveDB(bean).await();//非同步操作
}
複製程式碼
Kotlin的Coroutine和其他語言的協程一樣,擁有2大優點:更好的排程效能,非同步程式碼變同步。這裡不會討論協程如何使用,只是用到了協程;如果要學習協程,最好的資源就是Kotlin官方網站。
如何通知UI資料更新
如果你的API層寫在UI中,完全沒有這個問題,但這顯然不具有任何維護性和可擴充套件性。當我們將API單獨抽出一個層(一般是MVP的P層)的時候,資料獲取和處理的程式碼合UI分離了,必然面臨這個問題。
一般有3種處理方式:
- 自定義Callback
- 使用EventBus
- 使用LiveData
用自定義Callback的方式編寫的程式碼看起來像這樣:
class LoginPresenter{
fun login(username: String, psw: String, listener: OnLoginListener){
GlobalScope.launch {
Bean bean = client.url("https://github.com/li-xiaojun")
.header("a", "b")
.params("c", "d")
.<Bean>post().await() //非同步請求
bean?.apply{
listener.onLoginSuccess(this)
} ?: listener.onError(...)
}
}
}
複製程式碼
這種方式的需要每個邏輯都要自定義一個回撥,程式碼量巨大,且醜陋,不可取。
使用EventBus來通知UI,程式碼寫起來想這樣:
class LoginPresenter{
const EventLoginSuccess = "EventLoginSuccess"
const EventLoginFail = "EventLoginFail"
fun login(username: String, psw: String){
GlobalScope.launch {
Bean bean = client.url("https://github.com/li-xiaojun")
.header("a", "b")
.params("c", "d")
.<Bean>post().await() //非同步請求
if(bean!=null){
EventBus.get().post(new Event(EventLoginSuccess, bean))
}else{
EventBus.get().post(new Event(EventLoginFail, null))
}
}
}
}
複製程式碼
可以看到,EventBus的方式讓我們不用去定義大量的回撥,換了種方式去定義大量的Event標識。當專案複雜後,可能有上百個Event標識,並不容易管理。所以這種方式不是最佳的方式。
LiveData的方式程式碼寫起來像這樣:
class LoginPresenter{
var loginData = MutableLiveData<Bean>()
fun login(username: String, psw: String){
GlobalScope.launch {
Bean bean = client.url("https://github.com/li-xiaojun")
.header("a", "b")
.params("c", "d")
.<Bean>post().await() //非同步請求
loginData.postValue(bean)
}
}
}
複製程式碼
可以看到,LiveData的方式可以讓我們避免去定義回撥和Event的標識,寫法上更簡潔。更重要的是,LiveData天然能觀察UI生命週期變化,能避免一些記憶體洩漏,以及在最佳時刻更新UI。
MVP和MVVM
客戶端主要和UI打交道,最高效的架構一定是MVVM;前端的Vue和React已經完全證實了這一點。
Android上的MVVM主要有3種實現:
- LiveData和ViewModel
- DataBinding
- 基於Kotlin代理去實現VM層
其中DataBinding需要學習一些特定語法,和前端的Vue很像,而且因為用了反射,在複雜的更新頻率高的介面會有一點效能問題;不過也是很不錯的一種選擇。
Kotlin天然支援屬性代理,我們可以基於Kotlin的代理語法來實現UI的動態更新,不過這個需要一些精力。
個人最喜歡的是LiveData和ViewModel。
上個小節的Presenter層顯示沒有處理UI生命週期變化的邏輯,比如當UI結束時,Presenter是無法得知的,從而無法去釋放一些資源。你可以手動去寫一些程式碼,但是ViewModel是最佳選擇,它天然可以監視UI銷燬。所以換成ViewMode的程式碼是這樣的:
class LoginViewModel : ViewModel(){
var loginData = MutableLiveData<Bean>()
fun login(username: String, psw: String){
GlobalScope.launch {
Bean bean = client.url("https://github.com/li-xiaojun")
.header("a", "b")
.params("c", "d")
.<Bean>post().await() //非同步請求
loginData.postValue(bean)
}
}
//UI銷燬時執行
fun onCleard(){
//釋放資源的程式碼
}
}
複製程式碼
最佳實踐
綜上所述,根據我個人經驗得出的最佳實踐是:選擇OkHttp傳送請求,使用Kotlin Coroutine處理響應,用LiveData來通知UI更新;將這些邏輯抽象為VM層,具體表現為ViewModel。
網路請求本質上不就是從一個URL得到一個實體類嗎?這樣是不是更好一些呢?
GlobalScope.launch {
//get請求
val user = "https://github.com/li-xiaojun".http().get<User>().await()
//post請求
val user = "https://github.com/li-xiaojun".http()
.headers("token" to "xxaaav34", ...)
.params("phone" to "188888888",
"file" to file, //上傳檔案
...)
.post<User>()
.await()
}
複製程式碼
上面的程式碼使用我的開源庫AndroidKTX
就可以做到。有人說,這麼簡單,那支援其他請求方式,設定全域性Header,設定自定義攔截器,支援HTTPS嗎?這些是一個網路庫的基本功能,當然支援啦。
AndroidKTX
的Github地址是:github.com/li-xiaojun/…
所以,貼下我專案中API層的實踐程式碼:
class LoginViewModel : ViewModel(){
var loginData = MutableLiveData<User?>()
fun login(username: String, psw: String){
GlobalScope.launch {
val user = "https://github.com/li-xiaojun".http()
.params("phone" to "188888888", "password" to "111111")
.post<User>()
.await() // 為null表示請求失敗
loginData.postValue(user)
}
}
//UI銷燬時執行
fun onCleard(){
//釋放資源的程式碼
}
}
複製程式碼
UI層的程式碼大概是這樣:
class LoginActivity: AppCompatActivity() {
fun loadData(){
loginVM.loginData.observe(this, Observe {
it?.apply{ updateUI(it) } ?: toast("請求出錯")
})
//執行登入
loginVM.login(username, password)
}
}
複製程式碼