使用Kotlin协程制作一个计时工具类

Java回调的痛

Android在几年前普遍都使用Java开发。现在在Google宣布"Kotlin First"之后,加上不断高涨的Kotlin User数量,现在开始往kt转变了。Java语言,广为大家吐槽的一点就是异步操作需要通过回调来设置,而且还要特别注意线程切换,一不小心就会报:

sql 复制代码
Only the original thread that created a view hierarchy can touch its views

例如,我此前做过的一个弹窗倒计时更新按钮状态的需求,就要经历初始化一个倒计时工具类,然后设置回调,回调里写逻辑,最后再调用开始start。就像下面这样,设置过程中产生一堆override的回调,极其不美观。

typescript 复制代码
      CountDownUtils count = new CountDownUtils(5, 1000);
      count.setTimerCallBack(new CountDownUtils.OnTimerCallBack() {
            @Override
            public void onStart() {
                btn_close.setClickable(false);
                btn_close.getBackground().setAlpha(88);
                btn_close.setTextColor(Color.argb(80, 0xff, 0xff, 0xff));
                btn_close.setText("关闭 5s");
            }

            @Override
            public void onTick(int times) {
                btn_close.setText("关闭 " + times + "s");
            }

            @Override
            public void onFinish() {
                btn_close.getBackground().setAlpha(255);
                btn_close.setTextColor(Color.argb(255, 0xff, 0xff, 0xff));
                btn_close.setClickable(true);
                btn_close.setText("关闭");
            }
      });
      count.start();

后来又出了RxJava这个强大的异步库,但是其使用也过于复杂,各种操作符又有比较高的学习成本,做一个简单的倒计时的需求还用不上这个工具。在学习Kotlin之后,开始着手自己设计了一个简单的倒计时的工具类。

工具类用到的Kotlin的几个特性

单例类声明

过去Java中,单例工具类有一套通用的写法,一般都是getInstance同步方法,保证获取处返回单例:

csharp 复制代码
public class BSingleton {
    
    private static BSingleton bSingleton;

    private BSingleton() {
    }

    public synchronized static BSingleton getbSingleton(){
        if(bSingleton == null){
            bSingleton = new BSingleton();
        }
        return bSingleton;
    }
}

Kotlin贴心地优化了单例类的声明,直接以object关键字来声明一个单例类,内部的fun函数都是默认的static函数,外部可直接调用:

csharp 复制代码
object BSingleton{

}

默认函数参数

Java中遇到同名方法但是不同参数,一般会设置一堆重载函数来提供调用,在调用时其实最终也就是调用的参数最少的那一个。Kotlin添加了默认参数这个设计,在函数声明时,可以给参数一个默认值,调用时可以不传,函数执行时直接使用这个默认值。

kotlin 复制代码
fun printSomething(text:String = "default"){    
    Log.i("MYTAG", text)
}

// 调用时不写参数就是使用的text默认值"default"
printSomething()printSomething("my special text")

这一点可以有效消除重载方法的书写,另外还可以加入可空类型的使用,配合elvis符,?: 参数为空时,可以防止控制空指针错误。

函数式参数Lambda表达式

和上一个概念可能有点容易搞混淆,函数式参数其实可以理解成一种回调,而这个使用函数式参数的函数也叫高阶函数。

高阶函数的定义 :一个函数如果参数类型是函数或者返回值类型是函数,那么这就是一个高阶函数。Kotlin 支持函数作为参数传递,无需构建对象来包装函数。最典型的例子就是给按钮设置点击回调的时候,setOnClickListener就是一个高阶函数,例如下面的写法:binding.btnFastclick.setOnClickListener { infoLog("test") }

来看另一个简单的使用,getStringLenth函数,传进去一个字符串String对象a,和一个获取字符串长度的"函数对象" b,这个b实际上不是一个对象,实际上还是匿名内部类来实现的。

kotlin 复制代码
val lenth = getStringLenth("Android") {    it.length}

/** * 将另一个函数当作参数的函数称为高阶函数 */fun getStringLenth(str: String, getLenth: (String) -> Int): Int {    return getLenth(str)}

可以看到在定义时,第二个参数的类型是(String) -> Int)即其是一个接收String类型,返回Int类型的函数。调用处,当最后一个参数为函数式参数时,可以写成lambda表达式的写法,如上所示。

协程简单使用

协程是一种设计理念,Java在JDK21里加入的虚拟线程也是这样的设计理念。一种将代码任务在不同线程上切换,并支持使用者自由切换作用域,采用同步的方式书写代码的方便设计。举一个全网通用的例子。IO线程获取网络数据,展示到界面:

kotlin 复制代码
// viewModel或者controller里获取数据逻辑
// 使用suspend限制在协程里使用;withContext切换调度器,指定在IO线程执行下面的任务
suspend fun getUserName() = withContext(Dispatchers.IO) {    debugLog("thread name: ${Thread.currentThread().name}")    ServiceCreator.createService<UserService>()        .getUserName("2cd1e3c5ee3cda5a")        .execute()        .body()}
// Activity调用处
override fun onCreate(savedInstanceState: Bundle?){
// 最直接的声明方法,CoroutineScope(Dispatchers.Main),在主线程执行下面的逻辑
    CoroutineScope(Dispatchers.Main).launch {
        // 相当于get这一半是在IO线程执行,拿到结果后的变量赋值这一半操作由调度器自动切换到主线程来执行了
        val userName = mViewModel.getUserName()        infoLog("userName: $userName")        binding.tvUserName.text = userName
    }
}

以上就是设置一个suspend挂起式函数来获取网络数据可以理解成需要等待一段时间才会执行完毕的函数,只能在协程里或者其他挂起式函数里调用。

如果在其他地方调用则会编译报错提示:Suspend function 'XXXXXX' should be called only from a coroutine or another suspend function

协程里如果有延时操作,可以直接调用顶层函数delay()。顶层函数就是Java里的static函数,可直接写在KT文件中,不隶属于任意一个类。后面实现简单的计时类就是直接使用的delay(1000L),来实现1s的间隔tick操作。而且,delay为非阻塞式的挂起函数,即使在主线程delay很久,也不会出现ANR。如果我们不使用 delay(),而是使用 Thread.sleep(),那么协程就不会响应取消,而是继续执行,直到循环结束。这是因为 Thread.sleep() 是一个阻塞函数,它不会检查协程的取消状态,也不会抛出任何异常。因此,我们应该尽量避免在协程中使用阻塞函数,而是使用挂起函数。

实现CountDownUtil

CountDownUtil只有两个函数,一个开始start,一个取消cancel。

其中,start函数里需要传五个参数,其中总时长和步进(ms)需要能整除,否则抛出IllegalArgumentException。另外三个参数:

onStart: () -> Unit = {},

onTick: (currentTime: Int) -> Unit = {},

onFinish: () -> Unit = {}

这三个为函数式参数,分别是开始时触发onStart调用,步进触发onTick调用,结束时触发onFinish调用。且默认值均为空,调用时,可以都不传,也可以设置部分。

另外,为了取消初始化的new操作,在工具类里维护一个map,用id来对应执行计时操作的协程,需要取消计时时,通过id查询写成对象,来执行取消操作。

注意使用Map时还有一个坑,就是访问不存在的key时,其不会报错,只会返回一个空值。那么我们输错了id,协程也不会取消,也不会产生报错,非常尴尬。所以手动添加一个id检查,元素不在map里时抛出RuntimeException。

kotlin 复制代码
object CountDownUtil {    private val coroutingMap = mutableMapOf<Int, CoroutineScope>()    /**     * totalTime总时长,单位s     * interval步进,单位ms,默认值1000ms     * onStart和onFinish可空     */    fun start(        coroutineId: Int,        totalTime: Long,        interval: Long = 1000,        onStart: () -> Unit = {},        onTick: (currentTime: Int) -> Unit = {},        onFinish: () -> Unit = {}    ) {        // 整除校验        if ((totalTime * 1000 % interval).toInt() != 0) {            throw IllegalArgumentException("CountDownUtil: remainder is not 0")        }        // 和id一起加入map,方便后续定点cancel        // 如果你看过协程的官方文档或视频。你应该会知道Job和SupervisorJob的一个区别是,Job的子协程发生异常被取消会同时取消Job的其它子协程,而SupervisorJob不会。        val countDownCoroutine = CoroutineScope(            Dispatchers.Main + SupervisorJob()        )        coroutingMap[coroutineId] = countDownCoroutine        // 开始计时        countDownCoroutine.launch(CoroutineExceptionHandler { _, e ->            e.message?.let { errorLog(it) }        }) {            // 开始            onStart()            // 循环触发onTick            repeat((totalTime * 1000 / interval).toInt()) {                delay(interval)                onTick((totalTime * 1000 / interval - (it + 1)).toInt())            }            // 循环结束,触发onFinish            onFinish()        }    }    /**     * 以ID标识,取消协程,停止计时     */    fun cancel(coroutineId: Int) {        if (!coroutingMap.contains(coroutineId)) throw RuntimeException("Can't find your Id in the Coroutine map")        coroutingMap[coroutineId]?.cancel()        coroutingMap.remove(coroutineId)    }}

在调用时:

scss 复制代码
CountDownUtil.start(12355, 5, 1000,    onStart = { infoLog("CountDown  start") },    onTick = { infoLog("CountDown  tick: $it") },    onFinish = { infoLog("CountDown  finish ") })

// 延时3s取消测试
sleep(3000L)
CountDownUtil.cancel(123) // java.lang.RuntimeException: Can't find your Id in the Coroutine map

至此我们的计时工具类就完成了,在函数调用处,通过lambda表达式的方式来设置开始,进行中,结束的不同操作,消除传统的回调模式,对于开发者更友好。

如果只需要一个finish的操作,写法将更加优雅:

javascript 复制代码
CountDownUtil.start(12355, 5, 1000) { infoLog("CountDown finish ") }
相关推荐
这个DBA有点耶1 天前
NULL不是空——数据库里最反直觉的设计,90%新人踩过的坑
数据库·mysql·代码规范
To_OC6 天前
万字解析《JS 语言精粹》之第五章:继承 5 大核心精髓(JS 原型核心)
前端·javascript·代码规范
Coffeeee6 天前
闲聊几句,Android老哥们,你们多久没做技改需求了
android·程序员·代码规范
饼干哥哥6 天前
扣子3.0测评:我让 Codex 和 Claude Code 住同一个桌面,结果它们打架了!
人工智能·开源·代码规范
码哥字节8 天前
为什么 Claude Code 读你的代码库,光靠 embedding 根本不够?
claude·代码规范
kisshyshy10 天前
从递归到迭代,一文吃透二叉树的核心知识与 JavaScript 实现
javascript·算法·代码规范
用户69190268133914 天前
Vibe Coding 开发项目的基本范式
人工智能·设计模式·代码规范
Cosolar14 天前
藏在 Claude Code 里的极致浪漫:完整 187 条 Spinner Verbs 全收录
后端·程序员·代码规范
Mickey86115 天前
MCP 加持下的零代码逆向:全自动化绕过 APP 验签与加密实战
代码规范