Android 列表倒计时的实现(CountDownTimer)

实习一段时间了,一直想写点技术总结,但一直没找到合适的主题。刚好,最近版本中我负责的模块遇到了个线程相关问题(之前一直画界面,做点基础功能,有点乏味),列表项倒计时的实现。
于是乎,我的第一篇android技术文章就诞生了。

该demo用Kotlin语言实现。
demo最终效果图.gif

背景介绍

需要在ListView的item里实现倒计时,初看还挺简单的,但是真正做的时候也遇到了不少坑。
网上有不少类似文章,有用对TextView扩展实现的,也有用自带的CountDownTimer实现的,本文就是用CountDownTimer,只不过多了对服务器时间的刷新控制,更贴近项目需求吧。

刚学了点kotlin,就拿这个来练练手。所以这个demo的源码就用koltin实现了,想了解学习kotlin的也可以来交流下,刚学,代码里可能有些细节语法用的不好。

要点分析:
  • 倒计时需要根据请求所得服务器时间结束时间确定(所以要一个线程来维持服务器时间的运行,而且还有n个线程来维持item项的倒计时刷新显示)。
  • 既然是多线程,那么线程的控制就要注意

了解CountDownTimer

在看代码前,先来了解下android自带的CountDownTimer类用法

    private CountDownTimer timer = new CountDownTimer(30000, 1000) {  
        //根据间隔时间来不断回调此方法,这里是每隔1000ms调用一次
        @Override  
        public void onTick(long millisUntilFinished) {  
            //todo millisUntilFinished为剩余时间,也就是30000 - n*1000
        } 
        
        //结束倒计时调用    
        @Override  
        public void onFinish() {  
            //todo
        }  
}; 

//开始倒计时
timer.start();

//取消倒计时(译者:取消后,再次启动会重新开始倒计时)
timer.cancel();;

这里的入参再解释下new CountDownTimer(30000, 1000)
第一个参数30000代表倒计时的总时间,单位为ms,这里是30000ms,也就是30s。第二个参数1000就是刷新间隔,也就是回调onTick方法的间隔,单位也是ms,这里就是1s回调一次。

CountDownTimer相关参考文章:
http://blog.csdn.net/lilu_leo/article/details/6941724

OK,基础结束,接下来直接实现代码了。

代码实现

先看核心,也就是CountDownAdapter类,这里就简化UI,每个item只有一个textView来显示倒计时,布局XML就不放了,直接放代码

class CountDownAdapter(private var activity: ListActivity, private var data: ArrayList<Date>, private var systemDate: Date) : BaseAdapter() {

    private val timeMap = HashMap<TextView, MyCountDownTimer>()
    private val handler = Handler()
    private val runnable = object : Runnable {
        override fun run() {
            if (systemDate != null) {
                systemDate.time = systemDate.time + 1000
                Log.i("xujf", "服务器时间线程===" + systemDate + "==for==" + this)
                handler.postDelayed(this, 1000)
            }
        }
    }

    init {
        handler.postDelayed(runnable, 1000)
    }

    override fun getView(position: Int, convertView: View?, parent: ViewGroup?): View {
        var v: View
        var tag: ViewHolder
        var vo = data[position]
        if (null == convertView) {
            v = activity.layoutInflater.inflate(R.layout.item_count_down, null)
            tag = ViewHolder(v)

            v.tag = tag
        } else {
            v = convertView
            tag = v.tag as ViewHolder
        }

        //获取控件对应的倒计时控件是否存在, 存在就取消, 解决时间重叠问题
        var tc: MyCountDownTimer? = timeMap[tag.tvTime]
        if (tc != null) {
            tc.cancel()
            tc = null
        }

        //计算时间差
        val time = getDistanceTimeLong(systemDate, vo)
        //创建倒计时,与控件绑定
        val cdu = MyCountDownTimer(position, time, 1000, tag.tvTime)
        cdu.start()

        //[醒目]此处需要map集合将控件和倒计时类关联起来
        timeMap.put(tag.tvTime, cdu)

        return v
    }

    /**
     * 退出时清空所有item的计时器
     */
    fun cancelAllTimers() {
        var s: Set<MutableMap.MutableEntry<TextView, MyCountDownTimer>>? = timeMap.entries
        var it: Iterator<*>? = s!!.iterator()
        while (it!!.hasNext()) {
            try {
                val pairs = it.next() as MutableMap.MutableEntry<*, *>
                var cdt: MyCountDownTimer? = pairs.value as MyCountDownTimer
                cdt!!.cancel()
                cdt = null
            } catch (e: Exception) {
            }

        }
        it = null
        s = null
        timeMap.clear()
    }

    fun removeTimer(){
        handler?.removeCallbacks(runnable)
    }

    fun reSetTimer(date: Date) {
        removeTimer()
        systemDate = date
        handler.postDelayed(runnable, 1000)
    }

    override fun getItem(position: Int): Any = data[position]

    override fun getItemId(position: Int): Long = 0L

    override fun getCount(): Int = data.size

    internal inner class ViewHolder(view: View) {
        var tvTime = view.findViewById<TextView>(R.id.tv_time)
    }

    /**
     * 倒计时类,每间隔countDownInterval时间调用一次onTick()
     * index参数可去除,在这里只是为了打印log查看倒计时是否运行
     */
    private inner class MyCountDownTimer(internal var index: Int, millisInFuture: Long,
                                         internal var countDownInterval: Long, internal var tv: TextView
    ) : CountDownTimer(millisInFuture, countDownInterval) {

        override fun onTick(millisUntilFinished: Long) {
            //millisUntilFinished为剩余时间长
            Log.i("xujf", "====倒计时还活着===第 $index 项item======")
            //设置时间格式
            val m = millisUntilFinished / countDownInterval
            val hour = m / (60 * 60)
            val minute = (m / 60) % 60
            val s = m % 60
            tv.text = "倒计时  (${hour}小时${minute}分${s}秒)"
        }

        override fun onFinish() {
            tv.text = "倒计时结束"
            //todo 可以做一些刷新动作
        }
    }

    /**
     * 时间工具,返回间隔时间长
     */
    fun getDistanceTimeLong(one: Date, two: Date): Long {
        var diff = 0L
        try {
            val time1 = one.time
            val time2 = two.time
            if (time1 < time2) {
                diff = time2 - time1
            } else {
                diff = time1 - time2
            }
        } catch (e: Exception) {
            e.printStackTrace()
        }

        return diff
    }
}

这里主要的创建一个线程来保持服务器时间和N个item倒计时的“走”动。

保持服务器时间没什么好说的,就是Handler配合Runnable的循环调用,注意的是,当activity销毁时,别忘了调用CountDownAdapterremoveTimer()方法来取消handler的回调,防止内存泄漏。

重点就是item里的倒计时的线程控制,这里参照网上的一个比较好的方法,就是用HashMap<TextView, MyCountDownTimer>()来让MyCountDownTimer和item里的TextView关联起来,也就是每个item对应一个CountDownTimer,当关闭页面时或者刷新list时,可利用cancelAllTimers()方法来清除所有关联,避免内存泄漏。

以下是ListActivity,伪造一些时间数据

class ListActivity : AppCompatActivity() {

    private val list: ArrayList<Date> = ArrayList()
    private var countDownAdapter: CountDownAdapter? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_list)
        getDate()
        setDate()
    }

    private fun setDate() {
        if (countDownAdapter == null) {
            countDownAdapter = CountDownAdapter(this, list, Date())
            lv_count_down.adapter = countDownAdapter
            lv_count_down.onItemClickListener = AdapterView.OnItemClickListener { adapterView, view, i, l ->
                val intent = Intent(ListActivity@this, Main2Activity::class.java)
                startActivity(intent)
            }
        } else {
            //刷新数据时,重置本地服务器时间
            countDownAdapter!!.reSetTimer(Date())
            countDownAdapter!!.notifyDataSetChanged()
        }
    }

    private fun getDate() {
        for (i in 1..20) {
            var date = Date(Date().time + i * 1000 * 60 * 30)
            list.add(date)
        }

    }

    override fun onDestroy() {
        countDownAdapter?.cancelAllTimers()
        countDownAdapter?.removeTimer()
        super.onDestroy()
    }
}

这里在销毁activity前,清除了服务器时间线程和所有item计时器,防止关闭页面后线程失控而导致的内存泄漏。但是并没有在打开其他页面时清除,因为如果清除了的话,那么从其他界面返回至此activity时,倒计时已停止。

当然如果你的需求允许返回界面时重新请求加载数据的,可以在onStop()中,只不过这样体验不好

countDownAdapter?.cancelAllTimers()
countDownAdapter?.removeTimer()

运行效果

这里就看下我跑模拟机运行demo打印的Log:

1

嗯,本地的服务器时间每秒一次再跑着,没毛病。

再来看看item里的倒计时Log:

2.png

也没毛病,只有显示的那几项再跑,没出现失控线程。

关闭ListActivity页面后所有线程全销毁。点击item后进入新界面,所有计时线程都在运行,然后返回ListActivity倒计时也是再跑的(模拟机跑demo的时候由于性能问题,长时间可能会出现倒计时不统一,用真机会好很多。)

OK,最后给出源码地址:
https://github.com/xjf1128/ListCountDownDemo

小结&感想

刚接到这个需求时,感觉肯定不少坑。最终做完再理一理思路,其实也还好。最初的思路正确的话,能少踩点坑。其实就是线程的控制和CountDownTimer的使用,难度也不大。

我的第一篇技术文章写完了,算上demo的编写,花了2个晚上。希望接下去我能坚持写技术文章总结日常开发中遇到的坑。


有很多不足,望请各位大牛指出

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,042评论 25 707
  • 809字|4分钟 这一回看的我费尽了脑汁!红楼梦虽然是白话小说,二十二回用了N多...
    寒晗Tylor阅读 803评论 1 0
  • 今天学了跨域,迫不及待想跟大家分享!不妥之处希望大家指正。首先来明确一下“跨域”这个概念。跨域指的是,到外域去取数...
    z0nka1阅读 253评论 0 0
  • 很久没做噩梦了,兴许是年龄大了,胆子也大了。可是,就在前几天的一个夜里,我却被鬼压床了,折腾了好久,才醒过来。 初...
    青石问心阅读 557评论 17 9
  • 上帝是公平的,但世界是不公平的,除了时间。可能大学毕业初入社会的时候同样是一个起跑线,但因为时间规划上,差异也会越...
    飘渺_d65f阅读 306评论 0 0