最近整体过了一下项目的代码,发现一些小细节问题和小瑕疵比较多,这些问题大多具有一定的通性,随手记录一下。如果有人看到这篇文章,希望能对你有帮助。
Jetpack Collection vs Java Collection.
Map, Set等数据结构在项目中非常普遍的使用,很多情况下,这些数据结构需要存储的数据量都不大。
val map = mapOf<K, V>()
val set = setOf<k, V>()
其实Android为这些存储少量的数据的集合做了专门的优化,并且从framework.jar剥离出来,放到Jetpack工具包中。这些优化主要在内存上,能够有效降低内存使用。以Java的HashMap为例,每条记录使用Map.Entry来记录。除了必要的
Node<K,V>[] table(hash桶)来管理数据,还有Set<Map.Entry<K,V>> entrySet这样的数据来提升易用性。在存储数据量比较少的情况下,这些额外的数据可能会比实际存储的数据占的内存还多。
Jetpack Collection中的ArrayMap和ArraySet等数据结构专门为这些小数据量的情况做了优化,去除了Entry这些辅助的类对象的开销。
现在简单说明一下ArrayMap的实现方式,ArrayMap使用两个数组来存储数据,一个是int[] hash,用来存储Key键的hash值,另外一个是Object[]用来存储Key和Value的值,并且在扩容的时候,表现的非常吝啬,因为ArrayMap扩容行为很便宜,只是数组的Copy,使用的系统函数System::arraycopy,但是HashMap如果要扩容,代价就要大得多。
这里有个点特别说明一下,int[] hash这个数组要保持有序,在有序的前提下,可以使用二分查找。问题是如何在不断新增数据的同时,如何保持有序呢?原来在新增数据的时候,要进行类似于插入排序的操作,所以ArrayMap的put操作的时间复杂度,其实是O(n)吗?因为需要把数组中大于当前hash值的所有元素都往后移动。
例如,当前的int[] hash = {2, 5, 7, 9, 10}, 需要插入的Key的hash值是8,那么,9,10两个值需要往后移一位,然后在7后面设置为8。最后数组变为,
int[] hash = {2, 5, 7, 8, 9, 10}。
所以插入操作的时间复杂度真的为O(n)吗,其实我觉得不一定,因为操作的是一块连续的内存,连续内存的操作可以使用memmove或者memcpy,插入hash值时候的可以做到O(1),但有一个二分查找的过程,所以插入时间复杂度为O(lgn)。所以这种机制比较适合在小数据量的时候,因为HashMap的插入时间复杂度是O(1),但是这个1代表的常量会比较大,在数据量较小的时候lg(n)会比这个1更有效。
其实还有另外一个角度来看这个问题,如果插入的时间复杂度真的为O(n)的话,就不用保持这个数组有序了,直接顺序一次遍历检查即可,并且少了保持数组有序的开销,所以System::arraycopy的时间复杂度必然为O(1)了。所以ArrayMap的插入操作的的时间复杂度为O(lgn)吗?其实我真也不确定,System::arraycopy其实是native函数实现的,需要去看一下Android的具体实现,Android的实现可能和Java的库实现也不一样。通用库函数的设计,函数具体实现方式其实是很难的一个权衡取舍,这个就不在这里说了。对于这个问题,我也不查源代码了,因为数据量很小,不用纠结这个问题。如果有人感兴趣,可以求证一下,留个评论。
大家还记得SparseArray这个类吗?原理是ArrayMap一样,只是Key只能为Int,使用原始类型int,避免Integer的装箱,进一步提升效率。
现在这个Jetpack的Collection在慢慢丰富了,现在已经有10几个类了,在合适的场合使用,可以有效提升程序内存使用率,知道这些类的实现机制之后,也可以避免在不合适的时候使用这些类。
善用用位操作
我看到代码里面有几处的代码大致是这样的。
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
}
}
在这情况下,整数除法,而且除数是2,在这种情况下,可以使用位操作来提升效率。这些代码不是问题,更多的是一些更好的代码规范。
代码可以改为:
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
}
}
可能有些同学会这么觉得,这些位操作太受条件限制了,如果不是乘除2的幂次呢,是不是就不能用位运算了呢?
其实吧,未必呢?例如,如下代码:
fun time(a: Int): Int {
return a * 3
}
现在是一个整数乘以3,不是2呢,也不是4,这个地方可以有办法来提升效率的吗?其实想的话,也是可以的。可以用一下的方法来。
fun time(a: Int): Int {
return (a shr 1) + a
}
通过一个位移操作和一个加法操作来达到乘3的目的,位移应该是有一个指令就可以做到,加法也比乘法要快得多。
然后呢,还有哪些场景下可以使用位操作呢?应用层面一个典型的场景是用一个32位的整数来标识32个状态,一个Int数值,每一位标识一个状态,如果为1,状态为true,反之为false。Android 中的Intent就用一个Int数值来标识Intent各种状态。用来标识Activity的launch mode就是通过这些位的组合来标识的。其他的暂时没想到。
延迟计算
这个是一个很常见,但是很少引起人的注意,因为很长时间内,代码都是这样的。后来函数式编程比较流行之后,延迟计算才慢慢引起人的注意。先来看一段代码:
LogUtils.d(TAG, "Load done, totalListSize: ${list.size}, resultListSize: ${resultList.size}, code: $code, msg: $msg, hasMore: $hasMore")
这是一段常见的打Log的代码,也是从项目中直接拿出来的。在Debug版本中,会打出相应的信息,但是在Release的版本中,Log不会打出。原因是一般都会在函数中作做控制。一个可能的情况是用一个变量来控制。
fun d(tag: String, message: String) {
if (DEBUG) {
android.util.Log.d(tag, message)
}
}
如果DEBUG为false,虽然最终Log不会输出,但是已经产生了很多的无用功。Log Message已经被创建出来的,而且可以看到,有些情况下,代价还是不小的。有多个String被创建,如果类似的代码很多的话,还是会有不少不必要的开销的。
这种情况下,延迟计算就能派上用场了。如果能把Log Message的创建延迟到真正使用的时候,就可以了。如何做到呢?我们这样来定义函数:
fun d(tag: String, supplier: () -> String) {
if (DEBUG) {
Log.d(tag, supplier())
}
}
这样只有当真正使用的时候,才执行创建Log message。因为Java和Kotlin现在对函数式编程支持的很完善,语言本身已经定义了一些类,如Supplier,函数也可以写成如下的样子。
fun d(tag: String, Supplier<String> supplier) {
if (DEBUG) {
Log.d(tag, supplier.get())
}
}
调用的时候,相应的代码可以写成这样。
LogUtils.d(TAG) {
"Load done, totalListSize: ${list.size}, resultListSize: ${resultList.size}, code: $code, msg: $msg, hasMore: $hasMore"
}
当然,这个问题的终极解法方法应该是C/C++的宏定义,但是可惜Java/Kotlin并不支持。
延迟计算,其实在应用层应用非常广泛。假设有一个页面,有多个Tab,每个Tab有名称等相应的信息,还有一个Fragment与之相对于。但是只有当用户切换到相应的Tab时,Fragment才进行相应创建,这样可以提高效率。因为有可能用户不切换Tab就退出界面了。这种情况下,需要所有的Tab都要先创建出来,因为所有Tab的信息都要在开始的时候显示出来,但是对于的Fragment需要延迟创建。可能有其他更好的方法,但是一个比较合适的方式是代码如下写:
public class Tab {
public String titleName;
public Drawable drawable;
// 其他的一些属性
public Supplier<Fragment> action;
}
通过Tab类,把Tab相关的信息都封装起来,这个是最大的好处,封装很完整,包括Fragment,但是Fragment并不立即创建,而是通过延迟化,到真正需要使用的时候才进行创建。
类似的延迟技术的使用很多,不再举其他例子了。
设置Collection的Capacity
先摘录一段代码,而且我相信,绝大多数的工程师写类似的代码都是这样写的。
fun newInstance(param: String?): ContentFragment {
val fragment = ContentFragment()
val args = Bundle()
args.putString(ARG_PARAM, param)
fragment.arguments = args
return fragment
}
看不出有什么问题吧,我感觉至少99%的工程师都是这样写的。首先这样的写法没有问题,至少很多Android的官方的Demo也是这样写的。但是如果理解这段代码究竟干了什么事情,我们其实会发现有更好的写法。
首先,我们来看Bundle类,Bundle里面存储是Key-Value的数据,所以里面应该会包含一个Map,这个会是Kotlin函数mapOf<Key, Value>()返回的HashMap吗?
如果你看了本篇文章的第一节就会知道,Bundle是用来存储很少量的数据,绝大多数情况下,数据量是小于5的。所以Bundle内部其实是一个ArrayMap。
ArrayMap初始化的Capacity是多少呢,初始化为0。所以一开始进行put的时候,就会进行扩容,第一次是扩充Capacity为4。然后呢,其实我们只是需要Capacity为1,我们浪费了3。其实浪费的不是3,而是9。为什么呢,原因就是第一节所说的,ArrayMap里面有两个数组,一个是hash值的数组,另外一个是key和value的数组,没错,key和value是存在同一个数组里面的。所以当Capacity = 4的情况下,第一个数组长度为4,第二个数组是8。如果put了一个key-value的情况下,第一个数组被用了1,第二个数组被用了2,总共浪费的空间为(4-1) + (8-2) = 9。浪费的空间竟然比实际使用的空间还要多。
如果这个Bundle会存5个数据呢,在放入第5个数据时候,ArrayMap又会进行扩容,然后有会造成空间的浪费。
如果一开始你知道会有多少数据的话,就直接把Capacity设置好,这样是不是会更好呢。不会有扩容的性能浪费,也不会有空间的浪费。
是不是有人会说,其实我早就不这样写代码了,Kotlin其实提供了一个函数叫bundleOf(),上面的代码可以写成这样。
fun newInstance(param: String?): ContentFragment {
val fragment = ContentFragment()
fragment.arguments = bundleOf(ARG_PARAM to param)
return fragment
}
诚然,Kotlin的bundleOf函数里面也考虑了Capacity的问题。直接会设置正确的值。但是这个问题不是ArrayMap才会有的问题,在最经常用的ArrayList也会有同样的问题。所以我们在使用Collection的时候,如果我们知道具体的包含的数量,提前设置是个好主意。如果不是完全确定,但是大致知道范围,其实也可以帮助我们选择合适的值,至少最大程度的减少扩容造成的开销。
另外,bundleOf()这个函数好是好,但是平白无故的生成了原本不该被创建的Pair对象,然后又多了一次函数调用开销。
好吧,我感觉我是想多了。但是,我感觉上面的代码还有一个小问题,我们合在下一节继续讲。
滥用Kotlin语言特性
1. 扩展函数的滥用
先来看一段代码,代码里面使用了一些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,
)
}
}
}
第一眼看到这段代码,还是需要稍微认真的看一下,才知道这段代码究竟要干嘛,但是知道了究竟要干嘛之后,就会感觉这段代码写的不好,显啰嗦。好像一个小孩,学会了一个新技能,然后遇到不管什么事情,都想要用这个新技能来解决所有的问题。
Kotlin其实提供了很多很好用的工具,但是这些工具需要工程师来进行合理的选择和组合。当然前提是能够,对这些工具足够熟悉。有些时候,其实用最朴素的方式写的代码,才最简洁有效。重写一下上面的代码如下:
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)
}
}
这个代码写的一目了然,代码量也少了,也提升了代码的维护性。
2. 安全调用运算符
Kotlin的?.的是个好东西,把之前Java代码的判空操作的模板代码给消灭掉了,代码更简洁。
?.后面做了什么事情,如果去看下编译之后的Java字节码,就会知道,其实?.还是会被编译成之前工程师手动写的判空代码。这个是Kotlin的设计原则,消灭所有的模板代码。让工程师只写最重要的逻辑代码,也使代码最大程度的简洁化。但是,这仅仅是Kotlin语言设计者的美好愿望,在看到下面的代码之后。
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被重复判空了4次。知道一个工具的使用,最好要了解一下这个工具到底是怎么工作呢。
终极的目标,可以让代码先在工程师的脑子里先进行编译,看到编译后的代码,然后再在脑子里运行一下。没问题了,再在机器上执行编译和运行。我看到这个代码的时候,立即就能看到被编译成Java字节码的样子,原本美感的代码就被破坏了。
3. 可空类型和不可空类型
这是Kotlin比Java更先进的一个点,在Java 8中,Java使用Optional的类来想达到Kotlin想要做的事情,但是Kotlin的方案更方便,也更直接。
val str1: String
val str2: String?
一个变量,根据设计者的设计,可以拥有不同的类型。在Java中的String类型的变量,可以是一个正常的字符串,也可以为null,但是这两个值是截然不同的。工程师需要自己来做检查,才可知道到底存储的什么值。换句话说,在Java中,你没法设计一个变量,让这个变量只存储具体的值,不能存储null。
Kotlin的可空类型解决了Java存成的问题。在这个可空类型的逻辑中,可空类型并不是非空类型的封装,string和string?是两个不同的类型。前者使用的情况是,代表一个变量,这个变量一定是具有值的,如果这个值为null,程序就不能继续往下走,而是应该抛出异常,告知调用者,让调用者来处理这个异常情况。string?这个类型表示的是,这个变量为null是正常的,可以继续往下走。
好了,我们来看上一节最后说的问题。
fun newInstance(param: String?): ContentFragment {
val fragment = ContentFragment()
fragment.arguments = bundleOf(ARG_PARAM to param)
return fragment
}
这里的参数,param: String?
的类型,是否有想过,这个类型是可空类型,还是不可空类型?不管选择什么类型,提前是这个类型选择之前,必定要根据实际情况来进行判断。也就是这个ContentFragment这个ARG_PARAM在设计的时候,到底是怎么定位的。从全部的代码来看,这个应该是一个必须的量,不能存在为空的可能。
我看到很多的代码,对于选择可空类型和非空类型,过于随意。
再举个例子,还记得在Fragment中获取Context的如何获取吗?
Context requireContext()
Context getContext()
如果在Fragment onViewCreated
获取Context,应该通过调用哪个函数获取?在这个情况下,应该使用第一个,因为在你的设想中,这个地方返回就不能为空,你也不用对这个Context做判空处理。如果确实因为某种原因,requireContext()
返回的就是为null的,不做判空,程序不就崩了吗?如果真如所说,这种情况下,就应该让程序崩溃,因为程序状态已经不对了,是无法挽回的不对,这个时候越是努力挽回,就只能错的越多。另外,如果你往主线程的消息队列里面Post了一个任务,在这个任务中,你需要获取Context,你一定要选择第二个,因为在这个情况下,Context是可以为空的,为空不代表程序出错了。因为正常情况下,这个Context也可以为空,只是表示当前的Fragment已经detach了之前的Activity。Kotlin在这种情况下,强制你对可空变量进行处理。
4. 属性的Get方法
这也是一个常见的,容易滥用的地方。
val mColumnInterval: Int
get() = resources.getDimensionPixelSize(R.dimen.xxx)
好好的定义一个属性难道不好吗?非得使用get(),如果使用get()的话,每次使用到这个属性,都会执行这个方法,每次计算出来的都是同一个值,为什么不把这个好好的存储在变量中。以下是编译之后的代码:
public final int getMColumnInterval() {
return this.getResources().getDimensionPixelSize(R.dimen.xxx);
}
这个是典型的滥用。不仅是Get方法,还有lazy by的机制,也是常常会用到并不合适的地方。
滥用问题总结
Kotlin的各种语法糖也甜也不甜。花里胡哨的语法,掩盖了这些语法后面实际的处理过程。我们需要理解这些语法背后做的事情,根据我们实际要解决的问题,合理选择,有时候,像无锋重剑一样,大巧不工。
问题总结
其实还有很多其他的问题,比如在RecycleView的onBindView函数里面会创建对象,这样在滑动过程中就会有大量短生命周期的对象产生,严重的还会有内存抖动的情况。不同代码分支可以合并的问题,还有同一个代码分支里面,重复计算问题。对一些常见的编程问题,没有使用高效的方案等。因为这些都是一些小问题,小问题都有通性,现在总结了一点供大家参考。
如果要写出好的代码,我一直提倡代码需要先在脑子里先进行编译,再在脑子里运行一下,之后才是在机器上面编译和运行。