android开发优化代码质量的一些思考

不知道起什么标题比较合适,所以起了一个这么奇怪的标题。
本意是将自己的一些经验输出出来,但又不敢确定写出来的内容的质量,所以不敢起如何写优质代码类似这样的标题。

RecyclerView

主要是和adapter相关的代码,所以如果没有标明,就是adapter的方法。
getItemCount:这个方法是用来获取adapter里面item的数量,如果一个adapter里面只有一种type,那没必要优化,但如果有多种type,那就存在优化的空间。
假设一个adapter有:headerView1、headerView2和普通的item,这个item列表叫list。
// 一般做法能是这样的
override fun getItemCount(): Int {
    // headerView1 + headerView2 + item
    return 2 + list.size
}

一开始我就是写类似这样的代码,然后就一直在思考一个问题,能不能把注释去掉也能让别人读懂代码?然后就想到了下面这种方式。

class MyAdapter{   
    override fun getItemCount(): Int {
        return HEADER_VIEW_1 + HEADER_VIEW_2 + list.size
    }
    
    companion object{
        private cost val HEADER_VIEW_1 = 1
        private cost val HEADER_VIEW_2 = 1
    }
}

先说明一下,这里编译出来的代码实际上还是 2 + list.size,这个可以双击shift,然后输入show kotlin byte code,然后点一下右边的decompile看一下编译出来的class文件就可以知道了。

解释一下代码,这种做法就是给headrView1和headrView2起一个名字,并且他们的值都是1。这样当他们两者相加的时候,就不会影响最终的结果。如果在item下面还有其他view,比如loadView,就可以使用类似的方式在list.size后面再加一个loadView。不过这样做和直接3 + list.size就存在区别了,愿不愿意为此牺牲一下性能,就仁者见仁智者见智了。

然后这里还可以扩展,拿我在开发的项目进行举例。我开发的项目有一个orderList,这个list的headerView1和headerView2就是使用这种方式放到RecyclerView里面的。然后存在一种情况,有时会出现网络错误或者订单列表为空。所以就可以起一个ORDER_EMPTY_VIEW这样的名称,然后当判断为订单列表为空时,就返回HEADER_VIEW_1 + HEADER_VIEW_2 + ORDER_EMPTY_VIEW。这样做就依然可以保证在不写注释的情况下别人也看得懂代码。

getItemViewViewType:这个方法依然可以通过非常巧妙的方式优化,就能提高代码的可读性。
还是上面的headerView1和headerView2,这个方法拿到的参数是position。假设headerView1的position是0,headerView2的position是1。那可以起这样两个变量POSITION_HEADER_VIEW_1和POSITION_HEADER_VIEW_2,然后判断position,最后返回具体的viewType。

有多个header时position的处理:如果有多个header时,在操作存放数据的时候就经常需要position - headerSize,这种情况下,建议一开始就写一个方法来获取,比如getReadlPostion(),否则到最后可能出现一段代码复制到一堆地方。而且这样做了之后,阅读代码的人也不用去思考position - headerSize是什么意思,而是看到getRealPosition之后就能马上知道这是获取真实的position。此时,还能再借助上面提到的POSITION_HEADER_VIEW_1和POSITION_HEADER_VIEW_2做一些判断。比如headerView1和headerView2都需要onBindViewHolder方法设置数据,此时就能直接判断postion是不是等于POSITION_HEADER_VIEW_1或POSITION_HEADER_VIEW_2。

上面这两个综合举一个具体的例子

class MyAdapter : RecyclerView.Adapter<MyAdapter.ViewHolder>() {
    private val list = (0..100).map { "text:$it" }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
        return ViewHolder(parent)
    }

    override fun onBindViewHolder(holder: ViewHolder, position: Int) {
        when (position) {
            POSITION_HEADER_VIEW_1 -> {
                // code...
            }
            POSITION_HEADER_VIEW_2 -> {
                // code...
            }
            else -> {
                val realPosition = getRealPosition(position)
                // code...
            }
        }
    }

    override fun getItemCount(): Int {
        return HEADER_VIEW_1 + HEADER_VIEW_2 + list.size
    }

    override fun getItemViewType(position: Int): Int {
        return when (position) {
            POSITION_HEADER_VIEW_1 -> VIEW_TYPE_HEADER_VIEW_1
            POSITION_HEADER_VIEW_2 -> VIEW_TYPE_HEADER_VIEW_2
            else -> VIEW_TYPE_DEFAULT
        }
    }

    private fun getRealPosition(position: Int) = position - HEADER_VIEW_1 - HEADER_VIEW_2

	class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView)

    companion object {
        private const val HEADER_VIEW_1 = 1
        private const val HEADER_VIEW_2 = 1

        private const val POSITION_HEADER_VIEW_1 = 0
        private const val POSITION_HEADER_VIEW_2 = 1

        private const val VIEW_TYPE_DEFAULT = 0
        private const val VIEW_TYPE_HEADER_VIEW_1 = 1
        private const val VIEW_TYPE_HEADER_VIEW_2 = 2
    }
}

这样做了之后,即使以后headerView1或headeView2的position需要修改,也只是修改下面的常量,而不用修改太多地方。

onCreateViewHolder和onBindViewHolder:这两个方法也可以优化。可能很多人会经常习惯性写出下面这样的代码。

override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
    return when(viewType){
        VIEW_TYPE_HEADER_VIEW_1 -> {
            LayoutInflater.from(parent.context).inflate(R.layout.item_header_view_1, parent, false).let{
                HeaderView1ViewHolder(it)
            }
        }
        VIEW_TYPE_HEADER_VIEW_2 -> {
            LayoutInflater.from(parent.context).inflate(R.layout.item_header_view_2, parent, false).let{
                HeaderView2ViewHolder(it)
            }
        }
        else -> ViewHolder(parent)
    }
}

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
    when (position) {
        POSITION_HEADER_VIEW_1 -> {
            if(holder is HeaderView1ViewHolder){
                holder.setHeader("xxxx")
            }
        }
    }
}

这两个方法的代码如果放在一起的话,是没有什么问题的。直到有一次我在优化代码的时候,将整个ViewHolder移到外部,才察觉到这里也可以优化。并且两个方法同时优化可以大大提高代码的可维护性。
做法是将HeaderView1ViewHolder的所有代码和HeaderView2ViewHolder的所有代码都移到外部,不要放到adapter里面。这样做了之后,就会发现adapter整个类看起来简洁了不少。
但这样修改之后,就会发现,每次想要修改layout文件的时候,就需要找到adapter。而如果想要修改设置数据的代码,就需要找到ViewHolder,来来去去太麻烦了。所以干脆将创建ViewHolder的代码也丢到ViewHolder里面,然后提供一个静态方法调用。
这里为了示例代码不那么长,所以上面很多优化的代码没有写,毕竟这不是这个例子的重点。

// MyAdapter.kt
class MyAdapter: RecyclerView.Adapter<RecyclerView.ViewHolder>() {
    private val list = (0..100).map { "text:$it" }

    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
        return when (viewType) {
            1 -> HeaderView1ViewHolder.createNewInstance(parent)
            2 -> HeaderView2ViewHolder.createNewInstance(parent)
            else -> ViewHolder(parent)
        }
    }

    override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
        when (position) {
            1 -> {
                if (holder is HeaderView1ViewHolder) {
                    holder.setData("header1")
                }
            }
            2 -> {
                //code...
            }
            else -> {
                //code...
            }
        }
    }

    override fun getItemCount(): Int {
        return 2 + list.size
    }

    override fun getItemViewType(position: Int): Int {
        return when (position) {
            0 -> 1
            1 -> 2
            else -> 0
        }
    }

    class ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {}
}

// HeaderView1ViewHolder.kt
class HeaderView1ViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
    private val button = itemView.findViewById<Button>(R.id.button)

    private var data: String? = null

    init {
        // 提前将onClick在构造方法里面设置
        // 而不是在调用adapter的bindViewHolder的时候设置,这样就不用频繁设置
        button.setOnClickListener {
            data?.also {
                Toast.makeText(button.context, it, Toast.LENGTH_SHORT).show()
            }
        }
    }

    fun setData(data: String) {
        this.data = data
        button.text = data
    }

    companion object {
        fun createNewInstance(parent: ViewGroup): HeaderView1ViewHolder {
            // 把layout的代码写在这里之后,以后就算需要修改代码,也只需要到这里修改即可
            // 而不用在adapter里面修改,维护起来就比较轻松
            return LayoutInflater.from(parent).inflate(R.layout.item_header1_view,parent, false)
        }
    }
}

使用?而不使用!!

在写kotlin的时候,有些某些变量知道一定不为空,但类型使用了可能为空的类型。遇到这种问题,有些人可能就会使用!!去解决。我所在的团队,是不允许这样做的。一旦在代码review时发现这样的代码,会要求使用?,否则不会approval。
我认为这是一种非常好的做法,因为这样才能尽可能地保证代码在运行的时候不会出现异常。
如果不使用!!,可以使用let等方法去解决。

// strings.xml
<string name="default_value">-</string>

// 通过api拿到一个price和currency,需要将price和currency结合一起显示
val defaultValue = getString(R.string.default_value)
val price: BigDecimal? = null
val currency: Currency? = null
val priceText = price?.let { price ->
    currency?.let { currency ->
        price.toString().plus(" ").plus(currency.toString())
    }
} ?: defaultValue

retrofit2对RequestBody进行优化

retrofit2里面,如果请求参数需要传递的参数比较长,一般可以使用map或者对象然后将对象转换成RequestBody对象。前者我用得比较少,所以不清楚有什么优化空间。后者我偶尔会使用,但每次在写代码的时候,都需要写注释。后面我就一直在想,能不能在不编写注释的情况下,其他人也可以知道需要传递什么对象,后面就想到了解决方案。
我就拿我使用TG(telegram)的bot发送消息作为例子。由于某些原因,我需要使用TG的bot帮我发一些模板消息,否则每次都手动发,这样做挺麻烦的。

先说优化前的做法
BotService.kt

interface BotService {
    // SendMessageRequest
    @POST("/bot{botToken}/sendMessage")
    fun sendMessage(@Path("botToken") botToken: String, @Body body: RequestBody):Call<ResponseBody>
}

SendMessageRequest.kt

class SendMessageRequest {
    var chat_id: String? = null
    var text: String? = null
    var parse_mode: ParseMode? = null
    var disable_web_page_preview: Boolean = false
    var reply_markup: InlineKeyboardMarkup? = null
    var allow_sending_without_reply: Boolean = false
}

发送消息

val retrofit = RetrofitHelper.getTelegramApiInstance()
val botService = retrofit.create(BotService::class.java)
botService.sendMessage(MishakiHelperBot.getBotToken(), Gson().toJson(SendMessageRequest().also {
    it.chat_id = "@$CHANNEL_ID"
    // 设置各种参数
}).toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())).enqueue(object : Callback<ResponseBody> {
    ....
})

每次在传递参数的时候都要写Gson.toJson()这种长长的代码,虽然可以使用kotlin的扩展方法优化一下,但不管怎么处理,始终还是需要在sendMessage方法里面要求传递一个RequestBody对象,并且还需要编写注释,否则阅读代码的人就只能看调用该方法的代码才可以知道需要传递什么对象。
我一直的观点就是,可以的话,尽可能让代码不用注释也可以看懂。基于这样的指导思想,最后想到了解决方式。使用一种类似装饰器设计模式的方式解决。
TypeRequestBody.kt

// 定义泛型,要求传递目标对象的类型,然后在service里面的方法参数
// 只需要使用该对象,并设定好泛型,就等于规定好了要传递的对象的类型
// 这样就不需要通过注释来让调用者明白需要传递什么类型
class TypeRequestBody<T>(typeObject: T) : RequestBody() {
    private val targetRequestBody = Gson().toJson(typeObject)
        .toRequestBody("application/json; charset=utf-8".toMediaTypeOrNull())

    override fun contentType(): MediaType? = targetRequestBody.contentType()

    override fun contentLength(): Long = targetRequestBody.contentLength()

    override fun writeTo(sink: BufferedSink) {
        targetRequestBody.writeTo(sink)
    }
}

优化后的service类

interface BotService {
    @POST("/bot{botToken}/sendMessage")
    fun sendMessage(@Path("botToken") botToken: String,@Body body: TypeRequestBody<SendMessageRequest>): Call<ResponseBody>
}

使用

val retrofit = RetrofitHelper.getTelegramApiInstance()
val botService = retrofit.create(BotService::class.java)
botService.sendMessage(MishakiHelperBot.getBotToken(),TypeRequestBody(SendMessageRequest().also {
    it.chat_id = "@$CHANNEL_ID"
    // code            
})).enqueue(object : Callback<ResponseBody> {
    ...
})

可以看到,直接new一个TypeRequestBody方法,并new一个SendMessageRequest对象就行,不需要写一堆代码。

方法相关

这算是对刚写代码的人的一些建议吧,因为我在刚写代码的时候写方法也是挺随性的。对于有几年工作经验的开发者来说,我接下来要说的过于小儿科,如果各位觉得我说得不对,也非常欢迎帮我指正。

super方法:我以前在写代码的时候,override一个方法之后,非常喜欢看看父类的该方法是不是空实现,如果空实现,就去掉super方法。
后来自己思考了一下,发现这种做法并不好。因为这样做了之后,其他人在看代码的时候,如果不清楚父类的这个方法是否空实现,就需要到父类查看。所以除非不需要使用父类的实现,否则super方法最好留下。

父类的方法:父类的方法应该尽量只做自己必须做的事。自己没必要做的,应当交给子类去做,最好不要越界。否则就会出现,有时子类觉得父类的一些代码反而给自己带来麻烦,最后迫不得已,必须去掉super方法,再将父类的一些代码复制过来。
父类也应该为一些常用的代码提供方法,即便这些代码只有一句。
这里我举一个我在工作中的例子吧。项目中我需要做一个左滑删除的功能,我是ViewGroup写一个layour出来。这个layout可以通过getChildAt(0/1)获取显示的view和删除的view。所以直接调用getChildAt(0)是没问题的,但如果这样做,就必须加一行注释,否则别人在看代码的时候,就需要看一下上下文来理解这局代码的意思。而如果写一个方法,比如叫getShowView() = getChildAt(0),这样别人在看到代码时,就可以马上知道这句代码的作用。

单一职责原则:该原则虽然是希望一个类不要承担太多职责,只负责单一职责。我认为,该原则在定义方法这方面也同样试用。
一个方法如果处理的业务过于繁杂,就应该想办法将某些业务写到特定方法里面。如果在这方面做得比较好,其他人在阅读代码的时候,仅仅是看该方法的一些代码和调用的方法,就知道该方法处理了什么业务。如果对某个细节感兴趣,就点开特定方法查看相应代码即可。不过这方面确实也不好举什么例子,也没有比较好的方法论。所以有条件的话,在编码过程中就多注意这些细节,这样后面就不需要回过头来优化代码。如果时间确实比较紧急,那也可以事后再优化。

统一Listener/Callback的命名:这方面我倒是可以给出一些我在工作中做的事情。先说一下场景,团队中会将一些常用的UI写成自定义View,然后提供一些Listener。起初,我在给这些Listener命名的时候,都是根据该View写一个类似名称的Listener,并且给一个和View名称类似的成员变量。这样做其实一点问题都没有,但后面我在继承这些View想要设置Listener的时候,就发现这里也可以优化。做法是,在该自定义View内部写一个OnEventListener,然后提供特定方法,成员变量的名称也统一成onEventListener。这样做了之后,以后如果需要Listener,就直接闭着眼写onEventListener就可以了。所以如果有这方面的需求,可以设计一套规则,统一命名,从而提升开发效率。

方法定义:不清楚其他人在定义方法是基于什么样的标准去定义的,我自己大部分情况下在定义方法是比较随意的。之前也看过一些文章,说什么超过10行就必须定义方法,超过一个屏幕就必须定义一个方法什么的,但我觉得这些都有点像形式主义了。而且每个人的屏幕可能不都一样大,字体大小也各不相同,所以这一点看起来是不太可能的。

减少if-else嵌套:如果在一个方法里面if里面嵌套着if,if里面又嵌套着if,导致一个if里面嵌套着多个if。此时,就应该考虑优化代码,优化的方式也不难,将不符合条件的情况直接return,这样整个方法读起来就比较轻松了。这样做了之后,对于阅读代码的人来说,很多if的代码就可以直接不看,直接找到最后的执行代码。

用好kotlin的let方法;对于一些需要转换的值,不建议在该值外部套上其他代码,而是使用let/run这样的方法进行类转换,从而提高代码的可读性。我这样说可能不知道我在说什么,所以我用一段代码来举例子。
这是一段用于计算总文件大小的代码,可以看到,最后一行连续使用了几个let。并且也可以发现,写了这样几个let之后,代码逻辑非常清晰。

val totalSize = gameMap.flatMap { entry ->
    entry.value.map {entry.key to it}
}.mapNotNull {
    try {
        File(GameFileUtil.targetFile,"${it.first}/${it.second}").takeIf {it.isDirectory}?.let {
            it.listFiles()?.map{it.length()}?.sum()
        }
    }catch (ignore: Exception){
        null
    }
}.sum()
    .let {it.toDouble() / 1024.0.pow(4.0)}
    .let {DecimalFormat("0.00").format(it)}
    .let {"$it TB"}

再看看如果只用一个let时什么情况

.let { "${DecimalFormat("0.00".format(it.toDouble() / 1024.0.pow(4.0)))} TB" }

可以看到,如果只用一个let,所有的代码都必须写在一行里面,需要多读几次才能读懂。而如果使用多个,一行一行读下来,就会发现代码逻辑是很清晰的。

尽量避免闪退

测试在提bug的时候,大部分是medium,一些比较重要的bug是high。但如果是闪退的bug,一定是high以上。所以不管怎么样,一定要想办法避免app中的闪退问题,即使为了不会闪退显示错误的数据。

这里提一下我在某家公司看到的解决方案,算是一个我觉得比较好的方案。做法是:定义SafeActivity和SafeFragment。SafeActivity定义onSafeCreate,然后在Activity的onCreate里面调用onSafeCreate并try-catch,Fragment也是类似。这是一种非常简单的做法,而且也可以避免大部分的闪退。至于catch的处理,那边是用toast提示一句话,可能这样做不能满足所有场景,所以这个要怎么处理比较合适就看leader是怎么想的咯。
但当当在Activity或Fragment做一些简单的try-catch操作是不够的,还有一个比较常见的场景,就是各种onXxxListener,比如onClickListener。我刚入职那会,他们的开发语言使用的还是java,所以就算想做onSafeClickListener,也比较麻烦。后来,他们也开始使用kotlin,这个问题就简单多了。可以利用kotlin的扩展方法实现onSafeOnClickListener这种操作,这样就能再提升try-catch的覆盖率。

上面这两种做法可以保证绝大部分代码可以被覆盖到,那现在就可以从细节入手,将一些看起来可能容易出现Exception的代码也做一些try-catch。

先来一段万能代码

inline fun safeExecute(action: () -> Unit) {
    try {
        action()
    } catch (ignore: Exception) {
    }
}

如果某些代码即使出现了异常,也没必要做特别的处理,那可以直接使用这种简单粗暴的方式避免。
如果开发语言使用的是kotlin,并且懒得写以上的代码,也可以使用kotlin的runCatching。

kotlin.runCatching {
    Log.d("MainActivity","onFailure start")
    1 / 0
}.onFailure {
    Log.d("MainActivity","onFailure")
}
kotlin.runCatching {
    Log.d("MainActivity","onSuccess start")
}.onSuccess {
    Log.d("MainActivity","onSuccess")
}

// D/MainActivity: onFailure start
// D/MainActivity: onFailure
// D/MainActivity: onSuccess start
// D/MainActivity: onSuccess

在调用onFailure之后,可以直接调用onSuccess,而不用分开调用,这个看一下源码就知道了,不用过多赘述。onFailure有一个参数,类型是Throwable,为执行失败抛出的异常。onSuccess有一个参数,类型是根据runCatching最后一行代码的返回类型决定的。如上面的log,返回类型是int,所以这里的类型就是int,即log的返回值。runCatching还有一些其他方法,但我认为这2个是比较常用的,就提了出来,其他的自己去看源码吧。

date format:日期格式化也是一个容易出现闪退的情况,比如手抖,写错了时间格式,或者后台返回新的时间格式,都有可能出现异常。所以对日期格式化,最好加上try-catch,下面我再提供一段我推荐的代码,是我在工作时写出来的。

fun SimpleDateFormat.safeParseDateString(dateString: String?, defaultDate: Date? = null): Date? =
    try {
        dateString?.let {
            parse(it)
        } ?: defaultDate
    } catch (e: Exception) {
        Log.e("TAG", "parse date exception, format:${toPattern()} dateString: $dateString")
        defaultDate
    }

toPattern()是SimpleDateFormat的一个方法,用来获取要解析的格式。

kotlin的extension插件:虽然虽然google已经用ViewBinding代替这个插件,但应该不是所有团队都会即使更换(至少我现在的团队没有更换)。使用过kotlin的show kotlin buytecode的人应该都知道,这个插件最终原理还是findViewById那套。所以某些情况下,获取到的View是有空的可能,所以在使用的时候,最后也顺便使用?。如果觉得一直使用?很麻烦,也可以配合also/let/apply/run等函数一起使用,这样就可以减少?的使用次数。

网络请求:网络请求的响应代码里面,也有可能出现异常。可以像上面的SafeActivity一样,写一个类似的safe类,或者每次在处理响应代码的时候,也都及时加上try-catch。

getOrNull:kotlin的List很多xxOrNull的方法,个人建议,在使用List的时候,最好使用这些类似的方法,而不是使用get/[]等方法。虽然大部分情况下,那些方法也不会出现异常,但如果出现意外情况,导致抛异常,就得不偿失了。

单元测试

在工作一段时间之后,发现公司根本没有要求单元测试,然后到一些技术群问了一些安卓开发者,得到的回答都是没有(印象中没有得到肯定的答复)。直到我在20年9月份入职了一家公司,才对单元测试有做要求,要求达到多少多少覆盖率,使用的统计工具是sonar。一开始我做单元测试也有点应付性质的,但直到我有一次在写单元测试的时候,测出一个测试都没有测出来的bug,我才意识到单元测试的重要性。所以后面在写单元测试的时候,都是尽量覆盖到所有可能出现的情况,避免出现一些意外的bug。
关于单元测试,我个人的意见是,如果工作比较忙,可以对那些看起来容易出bug的代码写一些测试用例,每次都该完代码之后都跑一次测试用例。哪些是容易出现bug的就要自己评估了,我也没办法给出特定的方法论。

注释

尽管我上面一直在强调,尽可能保证写出来的代码不需要注释。但实际上,我也知道,完全不用写注释是不可能的。
所以如果某个方法的执行逻辑较为复杂,应当在写完代码后及时加上注释,并且当业务更新时,及时修改注释,防止注释与当前的代码逻辑无法互相对应。

结语

可以看到,很多写法其实是比较简单的,只是需要多加思考而已。在空闲时,可以花点时间去思考如何优化那些看起来不太舒服的代码,可能就可以想到一些解决方案。
不过有一点我还是想说清楚,虽然我在一些地方一直强调我希望可以通过优化代码让代码自带注释。但我不是在说,代码不需要注释。一些复杂的业务、算法或架构,是很难通过什么奇淫技巧去避免注释。这点可以从很多开源框架的代码看到,这些代码往往有着比较深层次的考虑,而必须将架构设计得比较复杂。我是不觉得这种代码可以没有注释,作者能做的,就是尽量把注释写得比较清楚。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值