稀有猿诉

十年磨一剑,历炼出锋芒,说话千百句,不如码二行。

Kotlin实战学习:撸一个程序猿老黄历

Talk is cheap, let’s code

前面一篇文章介绍Kotlin的基础知识,但光有知识是不够的,最重要的是要能在实战中使用Kotlin,这才不枉我们学习一回。这里就用纯Kotlin来开发一个简单的Android应用,以展示如何在实际项目中使用Kotlin。

走上Kotlin开发之路

在前面文章的尾部,我们展示了如何创建一个基于Kotlin安卓应用,但是过于简单,因为仅是代码换成了Kotlin,布局还是在用XML,这并没有用到Kotlin的全部。为了更加方便的开发Android应用,发挥Kotlin语言的巨大优势,JetBrains在发布Kotlin的同时,也发布一个专门用于Android开发的配套的库Anko,它最大的优势就是以DSL的方式来创建UI,下面我们来介绍一下Anko。

Anko

Anko是什么鬼,以及为何要用它

Anko库的目的是提高Android开发的效率,用Kotlin语言的优势。它有四大部分:Anko commons,Anko layouts,Anko SQLite和Anko Coroutines,这里我们不复制官方的介绍了,关于这四部分可以看一下官方的wiki。 其实,最大的变化就是布局,常规的Android项目,我们一般都是用XML来写布局XML呢,其实也没有啥大问题,在各种开发工具和开源库的帮忙下,效率也不低,但XML最大的问题就是啰嗦,要不然现在也不会被JSON取代。 当然,我们可以像前面的KotlinHello,显示的那样,布局使用XML,代码使用Kotlin,这没有任何问题,但Kotlin语言最大的特点是简洁,所以,使用Anko可以,非常简洁的,用更少的代码来实现同样的功能,代码少了,效率也就高了。

如何使用Anko

重写KotlinHello,来展示一下如何在项目中使用Anko,继续打开上篇文章中的KotlinHello项目,在app下面的build.gradle中的dependencies中添加:

1
implementation "org.jetbrains.anko:anko:$anko_version"

在其顶部定义anko_version变量:

1
ext.anko_version = '0.10.5'

重新gradle sync一下,完成后,就可以使用了。

编辑HelloActivity.kt,在onCreate里面,把除了super.onCreate以外的都删除,然后添加:

1
2
3
4
5
6
7
8
9
10
11
12
    val colorTable = listOf("#ff0000", "#00ff00", "#0000ff", "#ffff00", "#00ffff", "#ff00ff")

    verticalLayout {
        val name = editText()
        button("Say Hello") {
            onClick {
                val randomIndex = (Math.random() * colorTable.size).toInt()
                setBackgroundColor(Color.parseColor(colorTable[randomIndex]))
                toast("Hello, ${name.text}! with color ${colorTable[randomIndex]}")
            }
        }
    }

运行起来,就是这个样子的:

Kotlin Hello version 1

页面有点丑,稍美化下,展示如何添加布局的属性:

1
2
3
4
5
6
        padding = dip(20)
        val name = editText()
        name.lparams(width = matchParent) {
            topMargin = dip(20)
            bottomMargin = dip(30)
        }

最终就是这个样子了:

Kotlin Hello version 2

贴下完整代码: app/build.gradle:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
apply plugin: 'com.android.application'

apply plugin: 'kotlin-android'

apply plugin: 'kotlin-android-extensions'

ext.anko_version = '0.10.5'

android {
    compileSdkVersion 27
    defaultConfig {
        applicationId "net.toughcoder.kotlinhello"
        minSdkVersion 21
        targetSdkVersion 27
        versionCode 1
        versionName "1.0"
        testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
    }
    buildTypes {
        release {
            minifyEnabled false
            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation"org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version"
    implementation 'com.android.support:appcompat-v7:27.1.1'
    implementation 'com.android.support.constraint:constraint-layout:1.1.0'
    implementation "org.jetbrains.anko:anko:$anko_version"
    testImplementation 'junit:junit:4.12'
    androidTestImplementation 'com.android.support.test:runner:1.0.2'
    androidTestImplementation 'com.android.support.test.espresso:espresso-core:3.0.2'
}

HelloActivity.kt:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class HelloActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val colorTable = listOf("#ff0000", "#00ff00", "#0000ff", "#ffff00", "#00ffff", "#ff00ff")

        verticalLayout {
            padding = dip(20)
            val name = editText()
            name.lparams(width = matchParent) {
                topMargin = dip(20)
                bottomMargin = dip(30)
            }
            button("Say Hello") {
                onClick {
                    val randomIndex = (Math.random() * colorTable.size).toInt()
                    setBackgroundColor(Color.parseColor(colorTable[randomIndex]))
                    toast("Hello, ${name.text}! with color ${colorTable[randomIndex]}")
                }
            }
        }
    }
}

实战,撸一个程序猿老黄历

一个KotlinHello,还是过于toy,我们再来一个稍复杂点的小项目,以练手,考虑到Kotlin带来最大变化就是用Anko来写布局,所以我们弄个布局稍复杂的,所以,可以撸一个程序猿老黄历,它功能比较简单,主要就是布局,又不涉及网络,所以适合初学者练手。

需求理解

动手之前,先理解一下需求。我们要撸的是这个版本的程序员老黄历。 原理呢很简单,预定义一些事件,工具,饮品,方位等,然后用当前日期算出一个随机index,从预定义中取出一批,然后展示出来。其实呢,对于逻辑部分的代码,我们照抄就好,不用太关心。重点,是布局如何用Anko来实现。

上手开撸

  1. 新建一个package: calendar

create package calendar

  1. 在calendar中新建一个empty activity: CalendarActivity

create calendar activity

1
2
3
4
5
6
7
class CalendarActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        title = "Programmer Calendar"
    }
}
  1. 点击KotlinHello中的button时,跳到CalendarActivity
1
2
3
4
onClick {
    // other codes
    startActivity<CalendarActivity>()
}
  1. 开始撸布局 整体布局分五块:头部的日期,宜事抬头,宜事详细,坏事抬头,坏事详细,底部方向和指数。这其中,头部日期,可以用一个TextView来解决。宜和坏,是一样的,可以复用,宜(坏)事详细是一个列表,底部也是一个列表,但因为数目和每条内容是固定的,所以可以用三个View来解决。

总结:

  1. 根布局要是一个ScrollView,因为如果内容多时,或者屏幕太小时,可能会有超出屏幕的地方,所以根布局要能滑动。
  2. 中间好/坏, 以及好坏的具体事件,要用一个LinearLayout把两个包起来,因为好/坏的高度是由具体事件决定的,又要填充背景色,所以包上一层LinearLayout不可避免。
  3. 这样一来,从上到下,一个LinearLayout就可以了

运行效果

最终运行效果:

Final result of programmer calendar

最终代码

CalendarActivity,负责布局和展示

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
class CalendarActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        title = "程序猿老黄历"

        val calendar = ProgrammerCalendar()
        scrollView {
            verticalLayout {
                val dateString = buildSpanned {
                    append(calendar.genTodayString(), Bold)
                }
                val dateLabel = textView(dateString) {
                    id = View.generateViewId()
                    textColor = Color.BLACK
                    textSize = sp(6.4f).toFloat()
                    singleLine = true
                    textAlignment = View.TEXT_ALIGNMENT_CENTER
                }.lparams(width = matchParent, height = wrapContent) {
                    topMargin = dip(10)
                    bottomMargin = dip(10)
                }

                val (goodList, badList) = calendar.genTodayLuck()
                generateLuck("宜", Color.parseColor("#ffee44"), Color.parseColor("#dddddd"), goodList)

                generateLuck("忌", Color.parseColor("#ff4444"), Color.parseColor("#aaaaaa"), badList)

                // Direction
                val directionDetail = buildSpanned {
                    append("座位朝向:", Bold)
                    append("面向")
                    append(calendar.genDirection(), foregroundColor(Color.GREEN))
                    append("写程序,BUG最少。")
                }
                val direction = extraLabel(directionDetail)

                // Drink
                val drinkDetail = buildSpanned {
                    append("今日饮品:", Bold)
                    append(calendar.genDrinks())
                }
                val drink = extraLabel(drinkDetail)

                val girlDetail = buildSpanned {
                    append("女神亲近指数:", Bold)
                    append(calendar.genGirlsIndex())
                }
                val girl = extraLabel(girlDetail)
            }
        }
    }

    private fun @AnkoViewDslMarker _LinearLayout.extraLabel(detail: CharSequence): TextView {
        return textView(detail) {
            textSize = sp(5).toFloat()
            horizontalPadding = dip(10)
            verticalPadding = dip(6)
        }
    }

    private fun @AnkoViewDslMarker _LinearLayout.generateLuck(type: String,
                                                              typeColor: Int,
                                                              detailColor: Int,
                                                              eventList: List<Map<String, String>>) {
        linearLayout {
            orientation = LinearLayout.HORIZONTAL

            val good = textView(type) {
                id = View.generateViewId()
                textColor = Color.WHITE
                textSize = sp(14).toFloat()
                textAlignment = View.TEXT_ALIGNMENT_CENTER
                gravity = Gravity.CENTER
                backgroundColor = typeColor
            }.lparams(width = dip(100), height = matchParent)

            val goodDetail = verticalLayout {
                id = View.generateViewId()
                eventList.forEach {
                    val caption = textView(it[ProgrammerCalendar.NAME]) {
                        textColor = Color.BLACK
                        textSize = sp(6).toFloat()
                    }
                    val detail = textView(it[ProgrammerCalendar.DESCRIPTION]) {
                        textColor = Color.GRAY
                        textSize = sp(5).toFloat()
                        horizontalPadding = dip(2)
                    }
                }
                horizontalPadding = dip(15)
                backgroundColor = detailColor
                verticalPadding = dip(10)
            }.lparams(width = matchParent, height = wrapContent)
        }
    }
}

ProgrammerCalendar,这里是业务逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
class ProgrammerCalendar {
    companion object EventKey {
        const val NAME = "name"
        const val GOOD = "good"
        const val BAD = "bad"
        const val WEEKEND = "weekend"
        const val DATE = "date"
        const val TYPE = "type"
        const val DESCRIPTION = "description"
    }

    private val weeks = listOf( "日", "一", "二", "三", "四", "五", "六")
    private val directions = listOf("北方", "东北方", "东方", "东南方", "南方", "西南方", "西方", "西北方")
    private val activities = listOf(
            mapOf(NAME to "写单元测试", GOOD to "写单元测试将减少出错", BAD to "写单元测试会降低你的开发效率"),
            mapOf(NAME to "洗澡", GOOD to "你几天没洗澡了?", BAD to "会把设计方面的灵感洗掉", WEEKEND to "true"),
            mapOf(NAME to "锻炼一下身体", GOOD to "", BAD to "能量没消耗多少,吃得却更多", WEEKEND to "true"),
            mapOf(NAME to "抽烟", GOOD to "抽烟有利于提神,增加思维敏捷", BAD to "除非你活够了,死得早点没关系", WEEKEND to "true"),
            mapOf(NAME to "白天上线", GOOD to "今天白天上线是安全的", BAD to "可能导致灾难性后果"),
            mapOf(NAME to "重构", GOOD to "代码质量得到提高", BAD to "你很有可能会陷入泥潭"),
            mapOf(NAME to "使用%t", GOOD to "你看起来更有品位", BAD to "别人会觉得你在装逼"),
            mapOf(NAME to "跳槽", GOOD to "该放手时就放手", BAD to "鉴于当前的经济形势,你的下一份工作未必比现在强"),
            mapOf(NAME to "招人", GOOD to "你面前这位有成为牛人的潜质", BAD to "这人会写程序吗?"),
            mapOf(NAME to "面试", GOOD to "面试官今天心情Xiao很好", BAD to "面试官不爽,会拿你出气"),
            mapOf(NAME to "提交辞职申请", GOOD to "公司找到了一个比你更能干更便宜的家伙,巴不得你赶快滚蛋", BAD to "鉴于当前的经济形势,你的下一份工作未必比现在强"),
            mapOf(NAME to "申请加薪", GOOD to "老板今天心情很好", BAD to "公司正在考虑裁员"),
            mapOf(NAME to "晚上加班", GOOD to "晚上是程序员精神最好的时候", BAD to "", WEEKEND to "true"),
            mapOf(NAME to "在妹子面前吹牛", GOOD to "改善你矮穷挫的形象", BAD to "会被识破", WEEKEND to "true"),
            mapOf(NAME to "撸管", GOOD to "避免缓冲区溢出", BAD to "强撸灰飞烟灭", WEEKEND to "true"),
            mapOf(NAME to "浏览成人网站", GOOD to "重拾对生活的信心", BAD to "你会心神不宁", WEEKEND to "true"),
            mapOf(NAME to "命名变量\"%v\"", GOOD to "", BAD to ""),
            mapOf(NAME to "写超过%l行的方法", GOOD to "你的代码组织的很好,长一点没关系", BAD to "你的代码将混乱不堪,你自己都看不懂"),
            mapOf(NAME to "提交代码", GOOD to "遇到冲突的几率是最低的", BAD to "你遇到的一大堆冲突会让你觉得自己是不是时间穿越了"),
            mapOf(NAME to "代码复审", GOOD to "发现重要问题的几率大大增加", BAD to "你什么问题都发现不了,白白浪费时间"),
            mapOf(NAME to "开会", GOOD to "写代码之余放松一下打个盹,有益健康", BAD to "小心被扣屎盆子背黑锅"),
            mapOf(NAME to "打DOTA", GOOD to "你将有如神助", BAD to "你会被虐的很惨", WEEKEND to "true"),
            mapOf(NAME to "晚上上线", GOOD to "晚上是程序员精神最好的时候", BAD to "你白天已经筋疲力尽了"),
            mapOf(NAME to "修复BUG", GOOD to "你今天对BUG的嗅觉大大提高", BAD to "新产生的BUG将比修复的更多"),
            mapOf(NAME to "设计评审", GOOD to "设计评审会议将变成头脑风暴", BAD to "人人筋疲力尽,评审就这么过了"),
            mapOf(NAME to "需求评审", GOOD to "", BAD to ""),
            mapOf(NAME to "上微博", GOOD to "今天发生的事不能错过", BAD to "今天的微博充满负能量", WEEKEND to "true"),
            mapOf(NAME to "上AB站", GOOD to "还需要理由吗?", BAD to "满屏兄贵亮瞎你的眼", WEEKEND to "true"),
            mapOf(NAME to "玩FlappyBird", GOOD to "今天破纪录的几率很高", BAD to "除非你想玩到把手机砸了", WEEKEND to "true")
    )


    private val specials = listOf(
            mapOf(DATE to "20140214", TYPE to "BAD", NAME to "待在男(女)友身边", DESCRIPTION to "脱团火葬场,入团保平安。")
    )

    private val tools = listOf("Eclipse写程序", "MSOffice写文档", "记事本写程序", "Windows8", "Linux", "MacOS", "IE", "Android设备", "iOS设备")

    private val varNames = listOf("jieguo", "huodong", "pay", "expire", "zhangdan", "every", "free", "i1", "a", "virtual", "ad", "spider", "mima", "pass", "ui")

    private val drinks = listOf("水", "茶", "红茶", "绿茶", "咖啡", "奶茶", "可乐", "鲜奶", "豆奶", "果汁", "果味汽水", "苏打水", "运动饮料", "酸奶", "酒")

    private val today = GregorianCalendar()
    private val iday = today.get(Calendar.YEAR) * 10000 + (today.get(Calendar.MONTH) + 1) * 100 + today.get(Calendar.DAY_OF_MONTH)

    private fun random(seed: Int, index: Int): Int {
        var n = seed % 11117
        for (i in 0 until 100+index) {
            n *= n
            n %= 11117   // 11117 是个质数
        }
        return n
    }

    private fun isSomeday(): Boolean {
        return today.get(Calendar.MONTH) == 5 && today.get(Calendar.DAY_OF_MONTH) == 4
    }

    private fun star(num: Int): String {
        var result = ""
        var i = 0
        while (i < num) {
            result += "★"
            i++
        }
        while(i < 5) {
            result += "☆"
            i++
        }
        return result
    }

    private fun isWeekend(): Boolean {
        return today.get(Calendar.DAY_OF_WEEK) == 0 || today.get(Calendar.DAY_OF_WEEK) == 6
    }

    // 从 activities 中随机挑选 size 个
    private fun pickRandomActivity(activities: List<Map<String, String>>, size: Int): List<Map<String, String>> {
        val pickedEvents = activities.pickRandom(size)

        return pickedEvents.map { parse(it) }
    }

    // 从数组中随机挑选 size 个
    private fun <T> List<T>.pickRandom(size: Int): List<T> {
        var result = this.toMutableList()
        for (j in 0 until this.size - size) {
            val index = random(iday, j) % result.size
            result.removeAt(index)
        }

        return result
    }

    // 解析占位符并替换成随机内容
    private fun parse(event: Map<String, String>): Map<String, String> {
        var result = event.toMutableMap()

        if (result[NAME]?.indexOf("%v") != -1) {
            result[NAME] = result[NAME]?.replace("%v", varNames[random(iday, 12) % varNames.size])!!
        }

        if (result[NAME]?.indexOf("%t") != -1) {
            result[NAME] = result[NAME]?.replace("%t", tools[random(iday, 11) % tools.size])!!
        }

        if (result[NAME]?.indexOf("%l") != -1) {
            result[NAME] = result[NAME]?.replace("%l", (random(iday, 12) % 247 + 30).toString())!!
        }

        return result.toMap()
    }

    // 添加预定义事件
    // Should return two lists: GOOD list and BAD list, the item of list is a map(dictionary)
    private fun pickSpecials(goodList: MutableList<Map<String, String>>, badList: MutableList<Map<String, String>>) {
        specials.forEach {
            if (iday.toString() == it[DATE]) {
                if (it[TYPE] == GOOD) {
                    goodList.add(mapOf(NAME to it[NAME]!!, GOOD to it[DESCRIPTION]!!))
                } else {
                    badList.add(mapOf(NAME to it[NAME]!!, BAD to it[DESCRIPTION]!!))
                }
            }
        }
    }

    // 生成今日运势
    // Two part: from specials events and random picked from activities
    fun genTodayLuck(): Pair<List<Map<String, String>>, List<Map<String, String>>> {
        var theActivities = if (isWeekend()) activities.filter { it[WEEKEND] == "true" } else activities

        val goodList = ArrayList<Map<String, String>>()
        val badList = ArrayList<Map<String, String>>()
        pickSpecials(goodList, badList)

        val numGood = random(iday, 98) % 3 + 2
        val numBad = random(iday, 87) % 3 + 2
        val pickedEvents = pickRandomActivity(theActivities, numGood + numBad)

        // Add random picked from activities to GOOD/BAD list
        for (i in 0 until numGood) {
            goodList.add(mapOf(NAME to pickedEvents[i][NAME]!!, DESCRIPTION to pickedEvents[i][GOOD]!!))
        }
        for (i in 0 until numBad) {
            badList.add(mapOf(NAME to pickedEvents[numGood + i][NAME]!!, DESCRIPTION to pickedEvents[numGood + i][BAD]!!))
        }
        return Pair(goodList, badList)
    }

    fun genTodayString(): String {
        return "今天是" + today.get(Calendar.YEAR) +
                "年" + (today.get(Calendar.MONTH) + 1) +
                "月" + today.get(Calendar.DAY_OF_MONTH) +
                "日 星期" + weeks[today.get(Calendar.DAY_OF_WEEK) - 1]
    }

    fun genDirection(): String {
        val index = random(iday, 2) % directions.size
        return directions[index]
    }

    fun genGirlsIndex(): String {
        return star(random(iday, 6) % 5 + 1)
    }

    fun genDrinks(): String {
        return drinks.pickRandom(2).joinToString(", ")
    }
}

完整的代码可以到这里下载

用到的新特性

从代码中看到,除了上一篇文章外,还用到了一些Kotlin语言的特性:

Ranges

可以理解为区间,用于按某些范围来迭代,看一下例子中genTodayLuck方法就能明白。下面也简单的补充下:

1
2
for (i in 1..10) // 等同于for (int i = 1; i <= 10; i++)
for (i in 0 until 10) // 等同于for (int i = 0; i < 10; i++)

自己可以体会上面的两个不同,还有就是还可以用于if判断:

1
if (a in 1..10) // if (1 <= a && a <= 10)

默认步长是1, 当然也可以自定义:

1
for (i in 1..10 step 2) // => for (int i = 1; i <= 10; i += 2)

Extension function

可以给已存在的类添加方法,非继承也非组合的方式,与Object-C中的Category很像。这会让在基于某个类,执行某种操作时,非常的简洁,比如此例中的pickRandom方法,如果常规实现是把列表作为一个参数传入,但是用了Extension function后,使用的时候就仿佛这是Collection本身提供的一个方法一样,可读性与简洁性大大提升。

Companion object

与内部类概念类似,就是想在一个类的内部再声明一个类,就要用companion object,引用companion object的成员时可以省略它的类的名字,如示例中,在CalendarActivity中引用ProgrammerCalendar的companion object EventKeys时可以省略:

1
ProgrammerCalendar.DESCRIPTION

const关键字

上一篇文章,介绍过变量用var声明,常量用val来声明,那关键字const又是什么鬼呢?原来它用于声明类的顶级属性(用人话说,就是非内部类),其作用相当于Java中的static final:

1
const val NAME = "name" //相当于Java中的public static final String NAME = "name";

参考资料

Comments