最近整體過了一下項目的代碼,發(fā)現(xiàn)一些小細(xì)節(jié)問題和小瑕疵比較多,這些問題大多具有一定的通性,隨手記錄一下。如果有人看到這篇文章,希望能對你有幫助。
Jetpack Collection vs Java Collection.
Map, Set等數(shù)據(jù)結(jié)構(gòu)在項目中非常普遍的使用,很多情況下,這些數(shù)據(jù)結(jié)構(gòu)需要存儲的數(shù)據(jù)量都不大。
val map = mapOf<K, V>()
val set = setOf<k, V>()
其實Android為這些存儲少量的數(shù)據(jù)的集合做了專門的優(yōu)化,并且從framework.jar剝離出來,放到Jetpack工具包中。這些優(yōu)化主要在內(nèi)存上,能夠有效降低內(nèi)存使用。以Java的HashMap為例,每條記錄使用Map.Entry來記錄。除了必要的
Node<K,V>[] table(hash桶)來管理數(shù)據(jù),還有Set<Map.Entry<K,V>> entrySet這樣的數(shù)據(jù)來提升易用性。在存儲數(shù)據(jù)量比較少的情況下,這些額外的數(shù)據(jù)可能會比實際存儲的數(shù)據(jù)占的內(nèi)存還多。
Jetpack Collection中的ArrayMap和ArraySet等數(shù)據(jù)結(jié)構(gòu)專門為這些小數(shù)據(jù)量的情況做了優(yōu)化,去除了Entry這些輔助的類對象的開銷。
現(xiàn)在簡單說明一下ArrayMap的實現(xiàn)方式,ArrayMap使用兩個數(shù)組來存儲數(shù)據(jù),一個是int[] hash,用來存儲Key鍵的hash值,另外一個是Object[]用來存儲Key和Value的值,并且在擴(kuò)容的時候,表現(xiàn)的非常吝嗇,因為ArrayMap擴(kuò)容行為很便宜,只是數(shù)組的Copy,使用的系統(tǒng)函數(shù)System::arraycopy,但是HashMap如果要擴(kuò)容,代價就要大得多。
這里有個點特別說明一下,int[] hash這個數(shù)組要保持有序,在有序的前提下,可以使用二分查找。問題是如何在不斷新增數(shù)據(jù)的同時,如何保持有序呢?原來在新增數(shù)據(jù)的時候,要進(jìn)行類似于插入排序的操作,所以ArrayMap的put操作的時間復(fù)雜度,其實是O(n)嗎?因為需要把數(shù)組中大于當(dāng)前hash值的所有元素都往后移動。
例如,當(dāng)前的int[] hash = {2, 5, 7, 9, 10}, 需要插入的Key的hash值是8,那么,9,10兩個值需要往后移一位,然后在7后面設(shè)置為8。最后數(shù)組變?yōu)椋?br>
int[] hash = {2, 5, 7, 8, 9, 10}。
所以插入操作的時間復(fù)雜度真的為O(n)嗎,其實我覺得不一定,因為操作的是一塊連續(xù)的內(nèi)存,連續(xù)內(nèi)存的操作可以使用memmove或者memcpy,插入hash值時候的可以做到O(1),但有一個二分查找的過程,所以插入時間復(fù)雜度為O(lgn)。所以這種機(jī)制比較適合在小數(shù)據(jù)量的時候,因為HashMap的插入時間復(fù)雜度是O(1),但是這個1代表的常量會比較大,在數(shù)據(jù)量較小的時候lg(n)會比這個1更有效。
其實還有另外一個角度來看這個問題,如果插入的時間復(fù)雜度真的為O(n)的話,就不用保持這個數(shù)組有序了,直接順序一次遍歷檢查即可,并且少了保持?jǐn)?shù)組有序的開銷,所以System::arraycopy的時間復(fù)雜度必然為O(1)了。所以ArrayMap的插入操作的的時間復(fù)雜度為O(lgn)嗎?其實我真也不確定,System::arraycopy其實是native函數(shù)實現(xiàn)的,需要去看一下Android的具體實現(xiàn),Android的實現(xiàn)可能和Java的庫實現(xiàn)也不一樣。通用庫函數(shù)的設(shè)計,函數(shù)具體實現(xiàn)方式其實是很難的一個權(quán)衡取舍,這個就不在這里說了。對于這個問題,我也不查源代碼了,因為數(shù)據(jù)量很小,不用糾結(jié)這個問題。如果有人感興趣,可以求證一下,留個評論。
大家還記得SparseArray這個類嗎?原理是ArrayMap一樣,只是Key只能為Int,使用原始類型int,避免Integer的裝箱,進(jìn)一步提升效率。
現(xiàn)在這個Jetpack的Collection在慢慢豐富了,現(xiàn)在已經(jīng)有10幾個類了,在合適的場合使用,可以有效提升程序內(nèi)存使用率,知道這些類的實現(xiàn)機(jī)制之后,也可以避免在不合適的時候使用這些類。
善用用位操作
我看到代碼里面有幾處的代碼大致是這樣的。
class ItemDecorationHorizontalScroll(private val px: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, itemPosition: Int, parent: RecyclerView) {
outRect.left = px / 2
outRect.top = px / 2
outRect.right = px / 2
outRect.bottom = px / 2
}
}
在這情況下,整數(shù)除法,而且除數(shù)是2,在這種情況下,可以使用位操作來提升效率。這些代碼不是問題,更多的是一些更好的代碼規(guī)范。
代碼可以改為:
class ItemDecorationHorizontalScroll(private val px: Int) : RecyclerView.ItemDecoration() {
override fun getItemOffsets(outRect: Rect, itemPosition: Int, parent: RecyclerView) {
outRect.left = px shr 1
outRect.top = px shr 1
outRect.right = px shr 1
outRect.bottom = px shr 1
}
}
可能有些同學(xué)會這么覺得,這些位操作太受條件限制了,如果不是乘除2的冪次呢,是不是就不能用位運算了呢?
其實吧,未必呢?例如,如下代碼:
fun time(a: Int): Int {
return a * 3
}
現(xiàn)在是一個整數(shù)乘以3,不是2呢,也不是4,這個地方可以有辦法來提升效率的嗎?其實想的話,也是可以的??梢杂靡幌碌姆椒▉?。
fun time(a: Int): Int {
return (a shr 1) + a
}
通過一個位移操作和一個加法操作來達(dá)到乘3的目的,位移應(yīng)該是有一個指令就可以做到,加法也比乘法要快得多。
然后呢,還有哪些場景下可以使用位操作呢?應(yīng)用層面一個典型的場景是用一個32位的整數(shù)來標(biāo)識32個狀態(tài),一個Int數(shù)值,每一位標(biāo)識一個狀態(tài),如果為1,狀態(tài)為true,反之為false。Android 中的Intent就用一個Int數(shù)值來標(biāo)識Intent各種狀態(tài)。用來標(biāo)識Activity的launch mode就是通過這些位的組合來標(biāo)識的。其他的暫時沒想到。
延遲計算
這個是一個很常見,但是很少引起人的注意,因為很長時間內(nèi),代碼都是這樣的。后來函數(shù)式編程比較流行之后,延遲計算才慢慢引起人的注意。先來看一段代碼:
LogUtils.d(TAG, "Load done, totalListSize: ${list.size}, resultListSize: ${resultList.size}, code: $code, msg: $msg, hasMore: $hasMore")
這是一段常見的打Log的代碼,也是從項目中直接拿出來的。在Debug版本中,會打出相應(yīng)的信息,但是在Release的版本中,Log不會打出。原因是一般都會在函數(shù)中作做控制。一個可能的情況是用一個變量來控制。
fun d(tag: String, message: String) {
if (DEBUG) {
android.util.Log.d(tag, message)
}
}
如果DEBUG為false,雖然最終Log不會輸出,但是已經(jīng)產(chǎn)生了很多的無用功。Log Message已經(jīng)被創(chuàng)建出來的,而且可以看到,有些情況下,代價還是不小的。有多個String被創(chuàng)建,如果類似的代碼很多的話,還是會有不少不必要的開銷的。
這種情況下,延遲計算就能派上用場了。如果能把Log Message的創(chuàng)建延遲到真正使用的時候,就可以了。如何做到呢?我們這樣來定義函數(shù):
fun d(tag: String, supplier: () -> String) {
if (DEBUG) {
Log.d(tag, supplier())
}
}
這樣只有當(dāng)真正使用的時候,才執(zhí)行創(chuàng)建Log message。因為Java和Kotlin現(xiàn)在對函數(shù)式編程支持的很完善,語言本身已經(jīng)定義了一些類,如Supplier,函數(shù)也可以寫成如下的樣子。
fun d(tag: String, Supplier<String> supplier) {
if (DEBUG) {
Log.d(tag, supplier.get())
}
}
調(diào)用的時候,相應(yīng)的代碼可以寫成這樣。
LogUtils.d(TAG) {
"Load done, totalListSize: ${list.size}, resultListSize: ${resultList.size}, code: $code, msg: $msg, hasMore: $hasMore"
}
當(dāng)然,這個問題的終極解法方法應(yīng)該是C/C++的宏定義,但是可惜Java/Kotlin并不支持。
延遲計算,其實在應(yīng)用層應(yīng)用非常廣泛。假設(shè)有一個頁面,有多個Tab,每個Tab有名稱等相應(yīng)的信息,還有一個Fragment與之相對于。但是只有當(dāng)用戶切換到相應(yīng)的Tab時,F(xiàn)ragment才進(jìn)行相應(yīng)創(chuàng)建,這樣可以提高效率。因為有可能用戶不切換Tab就退出界面了。這種情況下,需要所有的Tab都要先創(chuàng)建出來,因為所有Tab的信息都要在開始的時候顯示出來,但是對于的Fragment需要延遲創(chuàng)建??赡苡衅渌玫姆椒?,但是一個比較合適的方式是代碼如下寫:
public class Tab {
public String titleName;
public Drawable drawable;
// 其他的一些屬性
public Supplier<Fragment> action;
}
通過Tab類,把Tab相關(guān)的信息都封裝起來,這個是最大的好處,封裝很完整,包括Fragment,但是Fragment并不立即創(chuàng)建,而是通過延遲化,到真正需要使用的時候才進(jìn)行創(chuàng)建。
類似的延遲技術(shù)的使用很多,不再舉其他例子了。
設(shè)置Collection的Capacity
先摘錄一段代碼,而且我相信,絕大多數(shù)的工程師寫類似的代碼都是這樣寫的。
fun newInstance(param: String?): ContentFragment {
val fragment = ContentFragment()
val args = Bundle()
args.putString(ARG_PARAM, param)
fragment.arguments = args
return fragment
}
看不出有什么問題吧,我感覺至少99%的工程師都是這樣寫的。首先這樣的寫法沒有問題,至少很多Android的官方的Demo也是這樣寫的。但是如果理解這段代碼究竟干了什么事情,我們其實會發(fā)現(xiàn)有更好的寫法。
首先,我們來看Bundle類,Bundle里面存儲是Key-Value的數(shù)據(jù),所以里面應(yīng)該會包含一個Map,這個會是Kotlin函數(shù)mapOf<Key, Value>()返回的HashMap嗎?
如果你看了本篇文章的第一節(jié)就會知道,Bundle是用來存儲很少量的數(shù)據(jù),絕大多數(shù)情況下,數(shù)據(jù)量是小于5的。所以Bundle內(nèi)部其實是一個ArrayMap。
ArrayMap初始化的Capacity是多少呢,初始化為0。所以一開始進(jìn)行put的時候,就會進(jìn)行擴(kuò)容,第一次是擴(kuò)充Capacity為4。然后呢,其實我們只是需要Capacity為1,我們浪費了3。其實浪費的不是3,而是9。為什么呢,原因就是第一節(jié)所說的,ArrayMap里面有兩個數(shù)組,一個是hash值的數(shù)組,另外一個是key和value的數(shù)組,沒錯,key和value是存在同一個數(shù)組里面的。所以當(dāng)Capacity = 4的情況下,第一個數(shù)組長度為4,第二個數(shù)組是8。如果put了一個key-value的情況下,第一個數(shù)組被用了1,第二個數(shù)組被用了2,總共浪費的空間為(4-1) + (8-2) = 9。浪費的空間竟然比實際使用的空間還要多。
如果這個Bundle會存5個數(shù)據(jù)呢,在放入第5個數(shù)據(jù)時候,ArrayMap又會進(jìn)行擴(kuò)容,然后有會造成空間的浪費。
如果一開始你知道會有多少數(shù)據(jù)的話,就直接把Capacity設(shè)置好,這樣是不是會更好呢。不會有擴(kuò)容的性能浪費,也不會有空間的浪費。
是不是有人會說,其實我早就不這樣寫代碼了,Kotlin其實提供了一個函數(shù)叫bundleOf(),上面的代碼可以寫成這樣。
fun newInstance(param: String?): ContentFragment {
val fragment = ContentFragment()
fragment.arguments = bundleOf(ARG_PARAM to param)
return fragment
}
誠然,Kotlin的bundleOf函數(shù)里面也考慮了Capacity的問題。直接會設(shè)置正確的值。但是這個問題不是ArrayMap才會有的問題,在最經(jīng)常用的ArrayList也會有同樣的問題。所以我們在使用Collection的時候,如果我們知道具體的包含的數(shù)量,提前設(shè)置是個好主意。如果不是完全確定,但是大致知道范圍,其實也可以幫助我們選擇合適的值,至少最大程度的減少擴(kuò)容造成的開銷。
另外,bundleOf()這個函數(shù)好是好,但是平白無故的生成了原本不該被創(chuàng)建的Pair對象,然后又多了一次函數(shù)調(diào)用開銷。
好吧,我感覺我是想多了。但是,我感覺上面的代碼還有一個小問題,我們合在下一節(jié)繼續(xù)講。
濫用Kotlin語言特性
1. 擴(kuò)展函數(shù)的濫用
先來看一段代碼,代碼里面使用了一些Kotlin的語言特性。
override fun onUpdateView(b: ViewDataBinding, data: SuggestCard) {
b.takeIf { it is HomeItemCategorySubSquareBinding }
?.let { it as HomeItemCategorySubSquareBinding }
?.let {
(repo.repoId == data.plid).let { isSelected ->
it.ivCardPlay.background = AppCompatResources.getDrawable(
context,
if (isSelected) R.drawable.home_ic_card_playing
else R.drawable.home_selector_card_play,
)
}
}
}
第一眼看到這段代碼,還是需要稍微認(rèn)真的看一下,才知道這段代碼究竟要干嘛,但是知道了究竟要干嘛之后,就會感覺這段代碼寫的不好,顯啰嗦。好像一個小孩,學(xué)會了一個新技能,然后遇到不管什么事情,都想要用這個新技能來解決所有的問題。
Kotlin其實提供了很多很好用的工具,但是這些工具需要工程師來進(jìn)行合理的選擇和組合。當(dāng)然前提是能夠,對這些工具足夠熟悉。有些時候,其實用最樸素的方式寫的代碼,才最簡潔有效。重寫一下上面的代碼如下:
override fun onUpdateView(b: ViewDataBinding, data: SuggestCard) {
if (b is HomeItemCategorySubSquareBinding) {
val resourceId = if (repo.repoId == data.plid) R.drawable.home_ic_card_playing else R.drawable.home_selector_card_play
b.ivCardPlay.background = AppCompatResources.getDrawable(context, resourceId)
}
}
這個代碼寫的一目了然,代碼量也少了,也提升了代碼的維護(hù)性。
2. 安全調(diào)用運算符
Kotlin的?.的是個好東西,把之前Java代碼的判空操作的模板代碼給消滅掉了,代碼更簡潔。
?.后面做了什么事情,如果去看下編譯之后的Java字節(jié)碼,就會知道,其實?.還是會被編譯成之前工程師手動寫的判空代碼。這個是Kotlin的設(shè)計原則,消滅所有的模板代碼。讓工程師只寫最重要的邏輯代碼,也使代碼最大程度的簡潔化。但是,這僅僅是Kotlin語言設(shè)計者的美好愿望,在看到下面的代碼之后。
fun parseParams(intent: Intent?) {
val p1 = intent?.data?.getQueryParameter("key1")
val p2 = intent?.data?.getQueryParameter("key2")
val p3 = intent?.data?.getQueryParameter("key3")
val p4 = intent?.data?.getQueryParameter("key4")
}
代碼看起來,也挺簡潔的啊,但是這簡潔的背后,是intent和intent的data被重復(fù)判空了4次。知道一個工具的使用,最好要了解一下這個工具到底是怎么工作呢。
終極的目標(biāo),可以讓代碼先在工程師的腦子里先進(jìn)行編譯,看到編譯后的代碼,然后再在腦子里運行一下。沒問題了,再在機(jī)器上執(zhí)行編譯和運行。我看到這個代碼的時候,立即就能看到被編譯成Java字節(jié)碼的樣子,原本美感的代碼就被破壞了。
3. 可空類型和不可空類型
這是Kotlin比Java更先進(jìn)的一個點,在Java 8中,Java使用Optional的類來想達(dá)到Kotlin想要做的事情,但是Kotlin的方案更方便,也更直接。
val str1: String
val str2: String?
一個變量,根據(jù)設(shè)計者的設(shè)計,可以擁有不同的類型。在Java中的String類型的變量,可以是一個正常的字符串,也可以為null,但是這兩個值是截然不同的。工程師需要自己來做檢查,才可知道到底存儲的什么值。換句話說,在Java中,你沒法設(shè)計一個變量,讓這個變量只存儲具體的值,不能存儲null。
Kotlin的可空類型解決了Java存成的問題。在這個可空類型的邏輯中,可空類型并不是非空類型的封裝,string和string?是兩個不同的類型。前者使用的情況是,代表一個變量,這個變量一定是具有值的,如果這個值為null,程序就不能繼續(xù)往下走,而是應(yīng)該拋出異常,告知調(diào)用者,讓調(diào)用者來處理這個異常情況。string?這個類型表示的是,這個變量為null是正常的,可以繼續(xù)往下走。
好了,我們來看上一節(jié)最后說的問題。
fun newInstance(param: String?): ContentFragment {
val fragment = ContentFragment()
fragment.arguments = bundleOf(ARG_PARAM to param)
return fragment
}
這里的參數(shù),param: String?的類型,是否有想過,這個類型是可空類型,還是不可空類型?不管選擇什么類型,提前是這個類型選擇之前,必定要根據(jù)實際情況來進(jìn)行判斷。也就是這個ContentFragment這個ARG_PARAM在設(shè)計的時候,到底是怎么定位的。從全部的代碼來看,這個應(yīng)該是一個必須的量,不能存在為空的可能。
我看到很多的代碼,對于選擇可空類型和非空類型,過于隨意。
再舉個例子,還記得在Fragment中獲取Context的如何獲取嗎?
Context requireContext()
Context getContext()
如果在Fragment onViewCreated獲取Context,應(yīng)該通過調(diào)用哪個函數(shù)獲???在這個情況下,應(yīng)該使用第一個,因為在你的設(shè)想中,這個地方返回就不能為空,你也不用對這個Context做判空處理。如果確實因為某種原因,requireContext()返回的就是為null的,不做判空,程序不就崩了嗎?如果真如所說,這種情況下,就應(yīng)該讓程序崩潰,因為程序狀態(tài)已經(jīng)不對了,是無法挽回的不對,這個時候越是努力挽回,就只能錯的越多。另外,如果你往主線程的消息隊列里面Post了一個任務(wù),在這個任務(wù)中,你需要獲取Context,你一定要選擇第二個,因為在這個情況下,Context是可以為空的,為空不代表程序出錯了。因為正常情況下,這個Context也可以為空,只是表示當(dāng)前的Fragment已經(jīng)detach了之前的Activity。Kotlin在這種情況下,強(qiáng)制你對可空變量進(jìn)行處理。
4. 屬性的Get方法
這也是一個常見的,容易濫用的地方。
val mColumnInterval: Int
get() = resources.getDimensionPixelSize(R.dimen.xxx)
好好的定義一個屬性難道不好嗎?非得使用get(),如果使用get()的話,每次使用到這個屬性,都會執(zhí)行這個方法,每次計算出來的都是同一個值,為什么不把這個好好的存儲在變量中。以下是編譯之后的代碼:
public final int getMColumnInterval() {
return this.getResources().getDimensionPixelSize(R.dimen.xxx);
}
這個是典型的濫用。不僅是Get方法,還有l(wèi)azy by的機(jī)制,也是常常會用到并不合適的地方。
濫用問題總結(jié)
Kotlin的各種語法糖也甜也不甜?;ɡ锖诘恼Z法,掩蓋了這些語法后面實際的處理過程。我們需要理解這些語法背后做的事情,根據(jù)我們實際要解決的問題,合理選擇,有時候,像無鋒重劍一樣,大巧不工。
問題總結(jié)
其實還有很多其他的問題,比如在RecycleView的onBindView函數(shù)里面會創(chuàng)建對象,這樣在滑動過程中就會有大量短生命周期的對象產(chǎn)生,嚴(yán)重的還會有內(nèi)存抖動的情況。不同代碼分支可以合并的問題,還有同一個代碼分支里面,重復(fù)計算問題。對一些常見的編程問題,沒有使用高效的方案等。因為這些都是一些小問題,小問題都有通性,現(xiàn)在總結(jié)了一點供大家參考。
如果要寫出好的代碼,我一直提倡代碼需要先在腦子里先進(jìn)行編譯,再在腦子里運行一下,之后才是在機(jī)器上面編譯和運行。