2018最新Kotlin基础视频教程上线了

从5月份开始制作这个视频,历时两个多月,终于在网易云课堂与大家见面了…

视频特色

  • 在前期课程中,使用普通编辑器进行编码,治疗你的IDE依赖症
  • 所有课程均使用live coding的方式授课,保持与你的频率一致
  • 每节课程课后都准备了相应的习题供大家巩固练习
  • 课程覆盖面广,线下效果持续跟踪
  • 项目实战部分是一个非常有意思的小项目,使用控制台完成类QQ聊天室功能(支持文字消息、文件发送、文件下载等功能)

课程目录

  • Kotlin基础环境配置、变量与空值安全
  • 一等公民函数基础语法
  • 高阶函数、lambda表达式与匿名函数
  • 面向对象知识点(类、接口、继承、扩展等)
  • Kotlin语言中多样的class类型、对象以及枚举
  • 强大的集合与控制流处理
  • 操作符重载
  • 更安全的泛型处理
  • 代理模式及其应用
  • 协程简介
  • 析构、注解与异常处理
  • Kotlin与Java互通的那些事儿
  • 控制台版本QQ聊天室项目实战

一些感想

这是我第一次真正尝试录制视频教程,种种原因,导致整个视频的录制、剪辑加发布审核一共花掉了两个多月,将近三个月。而且还是离职在家,全职的情况下。

即便如此,事实上,这个视频依然没有达到我的要求。但我想,第一次永远是青涩的,想要一步到位永远不过是痴人说梦。我坚信下一个视频我会做的更好。因此,也希望大家对这个视频提出宝贵的意见。

视频介绍看这里

在Kotlin交流群中,不少同学希望我能够出露脸视频。为了满足大家的愿望,我在课程的开始部分录制了一段真人解说视频。如果你不想看文字介绍的话,请移步到这里:http://study.163.com/course/courseLearn.htm?courseId=1005686004&share=2&shareId=400000000535034#/learn/video?lessonId=1052950023&courseId=1005686004

如何观看这个视频教程

目前,该视频教程仅上线了网易云课堂,同学们可以移步至这里观看:http://study.163.com/course/introduction.htm?courseId=1005686004&share=2&shareId=400000000535034

课程问题解惑

如果你在使用Kotlin视频教程的时候遇到了任何问题,欢迎来我的Kotlin语言交流群里面提问。只要是关于视频课程的内容,一定有问必答。如果你没有购买这个视频教程,也可以添加这个QQ交流群,群里的小伙伴都非常热情。如果遇到了Kotlin语言问题,也可以在群里提问,同样会有问必答。

视频课程资料

课程源码:https://github.com/yuanhoujun/kotlin-video-tutorial

习题答案:关注微信公众号【欧阳锋工作室】,回复“Kotlin视频教程”索取

Kotlin语言交流群

唯一官方Kotlin语言交流群:329673958,期待您的加入。

Android两行代码实现仿微信滑动返回效果

iPhone滑动关闭页面是一个非常讨喜的设计。滑动关闭可以让你聚焦屏幕内容,而不需要因为返回突然切换思维到屏幕下方寻找返回按钮。事实上,在使用Android手机的时候,我经常这样做。原因是,Android不同机型的返回按钮位置不一样。以至于在更换机型后我常常找不到返回按钮,需要一段时间的适应期。而滑动关闭就可以有效地避免这个问题,目前已经有很多类型的Android应用开始支持滑动关闭,比如你熟悉的微信、快手等都已经支持了滑动返回效果。使用 Snake 框架你只需要两行代码就可以搞定滑动关闭集成…

如果你还不知道Snake是什么,请关注简书下面的文章:

Snake

初体验

如果你需要在Activity中实现滑动关闭效果,使用如下两个步骤即可:

  • 在你的Application中对Snake进行初始化:Snake.init(this)
  • 在你的Activity类的onCreate方法中对其进行托管:Snake.host(this)

以上两个方法已经完成了Activity滑动关闭集成,为了开启滑动关闭功能,你还需要在Activity类顶部添加@EnableDragToClose注解

Snake设计思路

为了保证Snake框架尽可能灵活,我使用了注解实现单页固定滑动参数配置。而全局配置则使用单独的snake.xml文件进行配置。同时,为了支持动态关闭和开启,在Snake类中提供了相关API用于动态控制滑动关闭和开启。

设计目标

看过Snake官方文档的同学会发现,Snake并不提供左滑关闭或者其它方向关闭页面的设置,Snake也没有提供不同的关闭效果设置。没有这样设计的原因很简单,因为这种关闭效果并不常见,这样的设计不过是哗众取宠,浪费时间,且增加使用难度。

我的目标是:尽可能简化Snake设计,仅提供必要API,且专注于滑动关闭效果实现。

新版本来了

这是本篇文章的重点,昨天,Snake 0.3.0 版本已经发布了。

0.3.0版本主要针对Fragment提供了继承方式集成:

使用方法

按照下面的对应关系,改变你的Fragment父类就可以完成滑动关闭集成:

  • android.app.Fragment => com.youngfeng.snake.app.Fragment
  • android.support.v4.app.Fragment => com.youngfeng.snake.support.v4.app.Fragment

注意:使用继承方式集成的情况下,原来的API完全可以通用。你可以选择使用Snake的API进行滑动控制,也可以使用父类中的方法进行滑动控制,这取决于你自己。甚至实例创建你依然可以交给newProxy/newProxySupport接口。

详细信息,请查看官方文档:https://github.com/yuanhoujun/Android_Slide_To_Close

交流群

QQ群:288177681
如果你在使用Snake的过程中,遇到任何问题,请使用QQ群联系我。


我是 欧阳锋,开源的道路上,我与你同行。

Kotlin 操作符重载及中缀调用

操作符重载其实很有意思!但这个概念却很少有人知道,使用操作符重载在某种程度上会给代码的阅读带来一定的麻烦。因此,慎用操作符被认为是一个好习惯。的确,操作符重载是一把双刃剑,既能削铁如泥,也能“引火烧身”,这篇文章将从实用的角度来讲解操作符重载的基本用法。

支持重载的操作符类型

Kotlin语言支持重载的操作符类型比较多。以最新版本1.2.21为准,目前支持重载的操作符可以归纳为以下几类:

一元操作符

一元前缀操作符

操作符 对应方法
+a a.unaryPlus()
-a a.unaryMinus()
!a a.not()

以上三个操作符在日常使用中频率很高,第一个操作符在基本运算中很少使用,第二个操作符就是常见的取反操作,第三个操作符是逻辑取反操作。接下来,我们使用扩展的方式重载这三个操作符:

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
/**
* 一元操作符
*
* @author Scott Smith 2018-02-03 14:11
*/
data class Number(var value: Int)

/**
* 重载一元操作符+,使其对Number中实际数据取绝对值
*/
operator fun Number.unaryPlus(): Number {
this.value = Math.abs(value)
return this
}

/**
* 重载一元操作符-,使其对Number中实际数据取反
*/
operator fun Number.unaryMinus(): Number {
this.value = -value
return this
}

/**
* 这个操作符通常是用于逻辑取反,这里用一个没有意义的操作,来模拟重载这个操作符
* 结果:始终返回Number中实际数据的负值
*/
operator fun Number.not(): Number {
this.value = -Math.abs(value)
return this
}

fun main(args: Array<String>) {
val number = Number(-3)
println("Number value = ${number.value}")
println("After unaryPlus: Number value = ${(+number).value}")
println("After unaryMinus: Number value = ${(-number).value}")

number.value = Math.abs(number.value)
println("After unaryNot: Number value = ${(!number).value}")
}

运行上述代码,将得到如下结果:

1
2
3
4
Number value = -3
After unaryPlus: Number value = 3
After unaryMinus: Number value = -3
After unaryNot: Number value = -3

自增和自减操作符

操作符 对应方法
a++/++a a.inc()
a–/–a a.dec()

重载这个操作符相对比较难理解,官方文档有一段简短的文字解释,翻译成代码可以这样表示:

1
2
3
4
5
6
7
8
9
10
11
12
// a++
fun increment(a: Int): Int {
val a0 = a
a = a + 1
return a0
}

// ++a
fun increment(a: Int): Int {
a = a + 1
return a
}

看懂上面的代码后,我们换成需要重载的Number类,Kotlin最终会这样处理:

1
2
3
4
5
6
7
8
9
10
11
// Number++
fun increment(number: Number): Number {
val temp = number
val result = number.inc()
return result
}

// Number++
fun increment(number: Number): Number {
return number.inc()
}

因此,重载Number类自加操作符,我们可以这样做:

1
2
3
operator fun Number.inc(): Number {
return Number(this.value + 1)
}

重载自减操作符同理,完整代码请参考我的Git版本库:kotlin-samples

二元操作符

算术运算符

操作符 对应方法
a + b a.plus(b)
a - b a.minus(b)
a * b a.times(b)
a / b a.div(b)
a % b a.rem(b)
a..b a.rangeTo(b)

前5个操作符相对比较好理解,我们以a + b为例,举个一个简单的例子:

1
2
3
4
5
6
7
8
9
10
// 重载Number类的加法运算符
operator fun Number.plus(value: Int): Number {
return Number(this.value + value)
}

fun main(args: Array<String>) {
println((Number(1) + 2))
}
// 输出结果:
Number value = 3

相对比较难理解的是第六个范围运算符,这个操作符主要用于生成一段数据范围。我们认为Number本身就代表一个整型数字,因此,重载Number是一件有意义的事情。直接看例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
operator fun Number.rangeTo(to: Number): IntRange {
return this.value..to.value
}

fun main(args: Array<String>) {
val startNumber = Number(3)
val endNumber = Number(9)

(startNumber..endNumber).forEach {
println("value = $it")
}
}

// 运行结果:
value = 3
value = 4
value = 5
value = 6
value = 7
value = 8
value = 9

“In”运算符

操作符 对应方法
a in b b.contains(a)
a !in b !b.contains(a)

这个操作符相对比较好理解,重载这个操作符可以用于判断某个数据是否在另外一个对象中。我们用一个非常简单的自定义类来模拟集合操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class IntCollection { 
val intList = ArrayList<Int>()
}

// 重载"in"操作符
operator fun IntCollection.contains(value: Int): Boolean {
return this.intList.contains(value)
}

fun main(args: Array<String>) {
val intCollection = IntCollection()
intCollection.add(1, 2, 3)
println(3 in intCollection)
}

// 输出结果:
true

索引访问运算符

操作符 对应方法
a[i] a.get(i)
a[i, j] a.get(i, j)
a[i_1, …, i_n] a.get(i_1, …, i_n)
a[i] = b a.set(i, b)
a[i, j] = b a.set(i, j, b)
a[i_1, …, i_n] = b a.set(i_1, …, i_n, b)

这个操作符很有意思,例如,如果你要访问Map中某个数据,通常是这样的map.get("key"),使用索引运算符你还可以这样操作:

1
val value = map["key"]

我们继续以IntCollection类为例,尝试重写a[i]a[i] = b两个运算符,其它运算符同理。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 重载a[i]操作符
operator fun IntCollection.get(index: Int): Int {
return intList[index]
}

// 重载a[i] = b操作符
operator fun IntCollection.set(index: Int, value: Int) {
intList[index] = value
}

fun main(args: Array<String>) {
val intCollection = IntCollection()
intCollection.add(1, 2, 3)
println(intCollection[0])

intCollection[2] = 4
print(intCollection[2])
}

接下来,我们用索引运算符来做一点更有意思的事情!新建一个普通的KotlinUser

1
2
3
4
class User(var name: String,
var age: Int) {

}

使用下面的方式重载索引运算符:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
operator fun User.get(key: String): Any? {
when(key) {
"name" -> {
return this.name
}
"age" -> {
return this.age
}
}

return null
}

operator fun User.set(key: String, value:Any?) {
when(key) {
"name" -> {
name = value as? String
}
"age" -> {
age = value as? Int
}
}
}

接下来,你会神奇地发现,一个普通的Kotlin类居然也可以使用索引运算符对成员变量进行操作了,是不是很神奇?

1
2
3
4
5
6
fun main(args: Array<String>) {
val user = User("Scott Smith", 18)
println(user["name"])
user["age"] = 22
println(user["age"])
}

因此,索引运算符不仅仅可以对集合类数据进行操作,对一个普通的Kotlin类也可以发挥同样的作用。如果你脑洞足够大,你还可以发现更多更神奇的玩法。

调用操作符

操作符 对应方法
a() a.invoke()
a(i) a.invoke(i)
a(i, j) a.invoke(i, j)
a(i_1, ……, i_n) a.invoke(i_1, ……, i_n)

重载这个操作符并不难,理解它的应用场景却有一定的难度。为了理解它的应用场景,我们来举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
class JsonParser {

}

operator fun JsonParser.invoke(json: String): Map<String, Any> {
val map = Json.parse(json)
...
return map
}

// 可以这样调用
val parser = JsonParser()
val map = parser("{name: \"Scott Smith\"}")

这里的调用有点像省略了一个解析Json数据的方法,难道它仅仅就是这个作用吗?是的,调用操作符其实就这一个作用。如果一个Kotlin类仅仅只有一个方法,直接使用括号调用的确是一个不错的主意。不过,在使用的时候还是要稍微注意一下,避免出现歧义。

广义赋值操作符

操作符 对应方法
a += b a.plusAssign(b)
a -= b a.minusAssign(b)
a *= b a.timesAssign(b)
a /= b a.divAssign(b)
a %= b a.remAssign(b)

这个操作符相对比较好理解,我们以Number类为例,举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 广义赋值运算符
operator fun Number.plusAssign(value: Int) {
this.value += value
}

fun main(args: Array<String>) {
val number = Number(1)
number += 2
println(number)
}

// 输出结果:
Number value = 3

相等与不等操作符

操作符 对应方法
a == b a?.equals(b) ?: (b === null)
a != b !(a?.equals(b) ?: (b === null))

重载这个操作符与Java重写equals方法是一样的。不过,这里要注意与Java的区别,在Java端==用于判断两个对象是否是同一对象(指针级别)。而在Kotlin语言中,如果我们不做任何处理,==等同于使用Java对象的equals方法判断两个对象是否相等。

另外,这里还有一种特殊情况,如果左值等于null,这个时候a?.equals(b)将返回null值。因此,这里还增加了?:运算符用于进一步判断,在这个情况下,当且仅当b === null的时候,a、b才有可能相等。因此,才有了上面的对应关系,这里以User类为例举一个简单的例子:

1
2
3
4
5
6
7
8
9
10
class User(var name: String?,
var age: Int?) {

operator override fun equals(other: Any?): Boolean {
if(other is User) {
return (this.name == other.name) && (this.age == other.age)
}
return false
}
}

注意:这里有一个特殊的地方,与其它操作符不一样的地方是,如果使用扩展的方式尝试重载该操作符,将会报错。因此,如果要重载该操作符,一定要在类中进行重写。

比较操作符

操作符 对应方法
a > b a.compareTo(b) > 0
a < b a.compareTo(b) < 0
a >= b a.compareTo(b) >= 0
a <= b a.compareTo(b) <= 0

比较操作符是一个在日常使用中频率非常高的操作符,重载这个操作符只需要掌握以上表格中几个规则即可。我们以Number类为例举一个简单的例子:

1
2
3
operator fun Number.compareTo(number: Number): Int {
return this.value - number.value
}

属性委托操作符

属性委托操作符是一种非常特殊的操作符,其主要用在代理属性中。关于Kotlin代理的知识,如果你还不了解的话,请参考这篇文章
Delegation。这篇文章介绍的相对简略,后面会出一篇更详细的文章介绍代理相关的知识。

中缀调用

看到这里,可能有一些追求更高级玩法的同学会问:Kotlin支持自定义操作符吗?

答案当然是:不能!不过,别失望,infix也许适合你,它其实可以看做一种自定义操作符的实现。这里我们对集合List新增一个扩展方法intersection用于获取两个集合的交集:

1
2
3
4
5
6
7
8
9
10
11
// 获取两个集合的交集
fun <E> List<E>.interSection(other: List<E>): List<E> {
val result = ArrayList<E>()
forEach {
if(other.contains(it)) {
result.add(it)
}
}

return result
}

接下来,我们就可以在List及其子类中使用点语法调用了。但,它看起来仍然不像一个操作符。为了让它更像一个操作符,我们继续做点事情:

  • 添加infix关键词
  • 将函数名修改为∩(这是数学上获取交集的标记符号)
    然而,万万没想到,修改完成后居然报错了。Kotlin并不允许直接使用特殊符号作为函数名开头。因此,我们取形近的字母n用于表示函数名:
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    // 获取两个集合的交集
    infix fun <E> List<E>.n(other: List<E>): List<E> {
    val result = ArrayList<E>()
    forEach {
    if(other.contains(it)) {
    result.add(it)
    }
    }

    return result
    }

接下来,我们就可以这样调用了val interSection = list1 n list2,怎么样?是不是很像自定义了一个获取交集的操作符n?如果你希望自定义操作符,可以尝试这么做。

其实infix的应用场景还不止这些,接下来,我们再用它完成一件更有意思的事情。

在实际项目开发中,数据库数据到对象的处理是一件繁琐的过程,最麻烦的地方莫过于思维的转换。那我们是否可以在代码中直接使用SQL语句查询对象数据呢?例如这样:

1
val users = Select * from User where age > 18

纸上学来终觉浅,觉知此事需躬行。有了这个idea,接下来,我们就朝着这个目标努力。
一、先声明一个Sql类,准备如下方法:

1
2
3
4
5
6
7
infix fun select(columnBuilder: ColumnBuilder): Sql {

infix fun from(entityClass: Class<*>): Sql

infix fun where(condition: String): Sql

fun <T> query(): T

二、我们的目的是:最终转换到SQL语句形式。因此,增加如下实现:

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
class ColumnBuilder(var columns: Array<out String>) {

}

class Sql private constructor() {
var columns = emptyList<String>()
var entityClass: Class<*>? = null
var condition: String? = null

companion object {
fun get(): Sql {
return Sql()
}
}

infix fun select(columnBuilder: ColumnBuilder): Sql {
this.columns = columnBuilder.columns.asList()
return this
}

infix fun from(entityClass: Class<*>): Sql {
this.entityClass = entityClass
return this
}

infix fun where(condition: String): Sql {
this.condition = condition
return this
}

fun <T> query(): T {
// 此处省略所有条件判断
val sqlBuilder = StringBuilder("select ")

val columnBuilder = StringBuilder("")
if(columns.size == 1 && columns[0] == "*") {
columnBuilder.append("*")
} else {
columns.forEach {
columnBuilder.append(it).append(",")
}
columnBuilder.delete(columns.size - 1, columns.size)
}

val sql = sqlBuilder.append(columnBuilder.toString())
.append(" from ${entityClass?.simpleName} where ")
.append(condition)
.toString()
println("执行SQL查询:$sql")

return execute(sql)
}

private fun <T> execute(sql: String): T {
// 仅仅用于测试
return Any() as T
}
}

三、为了看起来更形似,再增加如下两个方法:

1
2
3
4
5
6
7
8
9
// 使其看起来像在数据库作用域中执行
fun database(init: Sql.()->Unit) {
init.invoke(Sql.get())
}

// 因为infix限制,参数不能直接使用可变参数。因此,我们增加这个方法使参数组装看起来更自然
fun columns(vararg columns: String): ColumnBuilder {
return ColumnBuilder(columns)
}

接下来,就是见证奇迹的时刻!

1
2
3
4
5
6
7
8
fun main(args: Array<String>) {
database {
(select (columns("*")) from User::class.java where "age > 18").query()
}
}

// 输出结果:
执行SQL查询:select * from User where age > 18

为了方便大家查看,我们提取完整执行代码段与SQL语句对比:

1
2
select          *       from User             where  age > 18
select (columns("*")) from User::class.java where "age > 18"

神奇吗?
至此,我们就可以直接在代码中愉快地使用类似SQL语句的方式进行方法调用了。

总结

本篇文章从操作符重载实用的角度讲解了操作符重载的所有相关知识。如文章开头所说,操作符重载是一把双刃剑。用得好事半功倍,用不好事倍功半。因此,我给大家的建议是:使用的时候一定要保证能够自圆其说,简单来说,就是自然。我认为相对于古老的语言C++来说,Kotlin语言操作符重载的设计是非常棒的。如果你知道自己在做什么,我非常推荐你在生产环境中使用操作符重载来简化操作。

本篇文章例子代码点这里:kotlin-samples


我是欧阳锋,一个热爱Kotlin语言编程的学生。如果你喜欢我的文章,请在文章下方留下你爱的印记。如果你不喜欢我的文章,请先喜欢上我的文章。然后再留下爱的印记!

下次文章再见,拜拜!


记一次印象深刻的Bug追踪过程

问题现象:使用安卓手机以小程序的形式分享产品到微信,使用微信打开,产品详情数据无法显示。而使用iPhone分享到微信,却始终可以正常打开,这个时候所有的矛头都指向了安卓同学。

小程序中打开,显示空白

逻辑设计说明:这里的分享数据来自H5接口,通过addJavascriptInterface自定义接口完成H5和Java端的数据传递,产品ID来自后台接口获取。

这个时候,安卓同学首先做出了响应,通过调试拿到了JS端的数据,以下是这位小陈同学的截图消息:

Android调试结果

小陈同学这个时候把问题抛给了Web前端同学小徐,以为小徐传递了科学计数法的ID字符串。

大家看小陈同学的截图,图中的ID是使用字符串接收的,这个时候我已经完全排除问题出现在安卓端的可能性了。于是,我问小徐,H5有对参数进行处理吗?得到的答案如下:

大家看到图中,我已经给出了确定的答案,认为问题来自于后台。因为,后台同学之前的确出现过对ID进行toInt处理最终转换为负数的情况。现在在传递时出现这种低级错误的概率应该也挺高的。这段话抛出去之后,团队炸开了锅,有同学认为大家在互相推诿…

其实,还有很长的截图,这里没有展示出来。群里提到最多的一句话就是:iOS没问题啊。就连我们的运维同学以及UI设计同学都加入了“讨伐”队伍,种种迹象似乎都指向了安卓同学。这个时候,我们的安卓同学真是“哑巴吃黄连,有苦说不出”,心里的潜台词肯定是:我TM的就用string接收了一下,我招谁惹谁了我!

但其实出现这种不知所踪的情况,完全可以理解,大家大都集中在单一平台开发,对于其它环节的理解难免有偏差。其实,用常识来理解这个问题的话,的确后台的概率比较大,前端同学对ID进行运算处理的概率几乎为0,这一点即使是刚刚入行的新手也不太可能。而我一直苦等的后台同学却迟迟没有响应,我目前始终无法确定问题到底来自于后台还是Web前端。直到我终于看到了下面的截图。

这个时候,我终于有九成的把握确定问题来自于Web前端了。可是,我知道我不能明说。前端同学已经在聊天记录中给出了证据,在Chrome的控制台打印出了正常的id值,到了安卓端却出现了异常。前端同学这个时候心里也有了一个定性结论,问题来自安卓端。这个时候,我只能亲自上场,而恰好我在外面,正在办理深圳户口,比较不便。于是,我微信给小陈发消息,嘱咐它把详情页的源码“爬”下来,我回来看看源码。

回到家的时候,我问小陈html源码是否已经“爬”了下来,他给我发来截图,我意识到前端使用了https协议,没法获取html源码。于是,我想了一个办法,在源码中嵌入一段代码,通过代码的形式获取WebView产品详情页的数据。这个方法果然奏效,不一会儿,小陈就发来了页面的html源码。

哎哟,我的天哪!混淆后的代码简直不堪入目,不过还好,我可以搜索方法关键字showShareView。可是,很遗憾没有搜索到,事件的绑定被放到了JS代码中。在这段源码中,我注意到一个文件名已经被混淆的JS文件,我猜想代码应该就在这里。可是,怎样抓到具体的方法呢?

灵机一动!我之前在代码中让小陈把Debug权限开发给了H5,这次正好可以派上用场。可是,对于混淆后的代码,我心里依然有点打退堂鼓。

连上手机,在Chrome浏览器中输入chrome://inpsect,点击相应链接,非常顺利地进入了调试界面:

在控制台的Source中,我通过关键词搜索找到了混淆后的JS代码片段,在方法名前面增加了一个断点,等调试到底方法位置的时候。这个时候已经获取到了JS的上下文,直接通过this.gid打印出了当前产品ID信息,居然是一个非常正常的整型数字。大家注意,这已经是一个在安卓端出问题的产品了,在JS端居然显示是正常的。这个时候,我的大脑非常转动,我的第一感觉应该是webkit内核看到接收的字符串全是数字做了”自以为是“的转换。于是,我给出了团队如下的答案:

为了进一步确定我的猜想,我让小陈写了一个简单的Demo,通过JS接口传递一个非常大的数字字符串给Java端,看接收是否异常。不一会儿,我就得到了答案:

至此,我终于基本确定问题的原因了!
猜测:JS在传递数据给安卓端的时候,应该是使用了基本数据类型。而webkit内核在处理的时候可能是以JS端数据类型为准,在传递到Java端时候做了转换。

为了验证这个猜想,我使用typeof打印id的数据类型,得到了如下结果:

于是,我告诉小徐,问题来自于你没有传递正确的数据类型给安卓端。其实这是比较危险的,不同CPU可以容纳的最大整型值是不一样的。如果iOS端和安卓处理一致,也是以JS端数据类型为准,只不过iOS的CPU字节宽度较大,恰好在iPhone高端机型上面没有出现而低端机型出现的话。其实问题依然存在,而如果iOS的确是以Native端数据类型为准。这就根本不是一个问题。但答案虽然给了团队,可是小徐仍然一脸狐疑,没有经验的CTO也是跟着一脸狐疑,加上解决问题的时间较长。小徐在发布更新的时候也遇到了问题,导致更新失败,问题持续,整个问题一直在持续。

这个时候,我告诉小徐,你发布更新后先别着急,确定更新成功后再告诉团队小伙伴。

一直到确定更新成功,我们再次尝试分享,问题终于引刃而解!

问题虽然解决了,可是,安卓系统为什么要这样处理呢?为什么不能以Native端数据类型为准呢?带着这个疑问,我开始查看安卓源码。

阅读安卓源码是一个痛苦的过程,随着系统版本的升级,安卓系统的兼容性代码越来越多,这给阅读带来了极大的困难。加上安卓系统本身源码量巨大,阅读源码就像在一个巨大的森林中寻找宝藏一样。这个时候,其实你非常容易迷路,而我知道,只要我坚信我想要什么,就一定可以找到。

这里我们以addJavascriptInterface这个方法作为突破口,进入源码:

1
2
3
4
public void addJavascriptInterface(Object object, String name) {
checkThread();
mProvider.addJavascriptInterface(object, name);
}

额,mProvider是什么鬼?难道WebView只是一个傀儡,真正处理业务的其实是mProvider?是的,没错!WebView只不过是一个壳而已!可是,mProvider的实现到底是什么呢?带着这个疑问,我们看到了如下mProvider实例创建的方法:

1
2
3
4
5
6
7
8
9
10
11
12
private void ensureProviderCreated() {
checkThread();
if (mProvider == null) {
// As this can get called during the base class constructor chain, pass the minimum
// number of dependencies here; the rest are deferred to init().
mProvider = getFactory().createWebView(this, new PrivateAccess());
}
}

private static WebViewFactoryProvider getFactory() {
return WebViewFactory.getProvider();
}

又出现了一个工厂方法,别怕,继续往下追踪:
getProvider方法较长,我们截取部分,看下面源码:

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
static WebViewFactoryProvider getProvider() {
synchronized (sProviderLock) {
// For now the main purpose of this function (and the factory abstraction) is to keep
// us honest and minimize usage of WebView internals when binding the proxy.
if (sProviderInstance != null) return sProviderInstance;

final int uid = android.os.Process.myUid();
if (uid == android.os.Process.ROOT_UID || uid == android.os.Process.SYSTEM_UID
|| uid == android.os.Process.PHONE_UID || uid == android.os.Process.NFC_UID
|| uid == android.os.Process.BLUETOOTH_UID) {
throw new UnsupportedOperationException(
"For security reasons, WebView is not allowed in privileged processes");
}

StrictMode.ThreadPolicy oldPolicy = StrictMode.allowThreadDiskReads();
Trace.traceBegin(Trace.TRACE_TAG_WEBVIEW, "WebViewFactory.getProvider()");
try {
Class<WebViewFactoryProvider> providerClass = getProviderClass();
Method staticFactory = null;
try {
staticFactory = providerClass.getMethod(
CHROMIUM_WEBVIEW_FACTORY_METHOD, WebViewDelegate.class);
} catch (Exception e) {
if (DEBUG) {
Log.w(LOGTAG, "error instantiating provider with static factory method", e);
}
}

这里的单用户检测,安全调用之类的代码就先忽略了。集中注意力看Provider实例创建的代码,大家可以看到,这里的创建其实通过反射调用创建的。这里有一个关键的方法getProviderClass(),这个方法可能获取到真正的Provider类对象,跟踪这个方法调用,我们看到了如下的调用过程:
getProviderClass() -> getWebViewProviderClass

1
2
3
4
5
public static Class<WebViewFactoryProvider> getWebViewProviderClass(ClassLoader clazzLoader)
throws ClassNotFoundException {
return (Class<WebViewFactoryProvider>) Class.forName(CHROMIUM_WEBVIEW_FACTORY,
true, clazzLoader);
}

看到了吗?CHROMIUM_WEBVIEW_FACTORY 这才是真正的WebViewFactoryProvider类声明,跟进这个常量:

1
private static final String CHROMIUM_WEBVIEW_FACTORY = "com.android.webview.chromium.WebViewChromiumFactoryProviderForO";

从命名ForO来看,这个类恰好是用于最新版本Android系统Oreo的。没错,这里我们就从最新版本的源码入手,找到真正的问题”元凶“。

可是,这个代码在哪里呢?你搜索安卓源码,根本搜索不到该类,这是为什么呢?也许你已经猜到了,其实这段代码就来自于Chrome核心工程 chromium。这段代码,大家通过谷歌搜索找找看,这里我们以官方版本的代码为准:
WebViewChromiumFactoryProviderForO

具体代码很简单,如下:

1
2
3
4
5
6
7
8
9
package com.android.webview.chromium;
class WebViewChromiumFactoryProviderForO extends WebViewChromiumFactoryProvider {
public static WebViewChromiumFactoryProvider create(android.webkit.WebViewDelegate delegate) {
return new WebViewChromiumFactoryProviderForO(delegate);
}
protected WebViewChromiumFactoryProviderForO(android.webkit.WebViewDelegate delegate) {
super(delegate);
}
}

LOL,可是,你以为真的很简单吗?其实不然,实现在父类,跟进父类。这个时候千万保持清醒,别跟丢了哦。我们想要的是Provider的创建过程,这个是Provider工厂类的真正类型,由它完成WebViewProvider的创建。

如果你已经忘了,我们再来回顾一下刚刚创建WebViewProvider的代码,别走神,看这里:

1
2
3
4
5
6
7
8
private void ensureProviderCreated() {
checkThread();
if (mProvider == null) {
// As this can get called during the base class constructor chain, pass the minimum
// number of dependencies here; the rest are deferred to init().
mProvider = getFactory().createWebView(this, new PrivateAccess());
}
}

看到了吗?这里拿到工厂类之后,调用了createWebView方法创建了Provider对象。那好办了,我们在WebViewChromiumFactoryProviderForO的父类WebViewChromiumFactoryProvider直接搜索createWebView方法即可。

1
2
3
4
@Override
public WebViewProvider createWebView(WebView webView, WebView.PrivateAccess privateAccess) {
return new WebViewChromium(this, webView, privateAccess, mShouldDisableThreadChecking);
}

怎么样,这段代码熟悉吗?这里直接返回了一个WebViewChromium对象,也就是说,WebView的所有操作,都由WebViewChromium帮忙完成。好吧,我们继续跟进这个类。可是跟进这个类做什么呢?哈哈,忘了吧,我们的目的是寻找addJavascriptInterface实现。稍等,容我先擦一把汗。

1
2
3
4
5
6
7
8
9
10
11
12
13
@Override
public void addJavascriptInterface(final Object obj, final String interfaceName) {
if (checkNeedsPost()) {
mFactory.addTask(new Runnable() {
@Override
public void run() {
addJavascriptInterface(obj, interfaceName);
}
});
return;
}
mAwContents.addJavascriptInterface(obj, interfaceName);
}

稍微瞅一眼这个方法checkNeedsPost

1
2
3
4
5
6
7
protected boolean checkNeedsPost() {
boolean needsPost = !mFactory.hasStarted() || !ThreadUtils.runningOnUiThread();
if (!needsPost && mAwContents == null) {
throw new IllegalStateException("AwContents must be created if we are not posting!");
}
return needsPost;
}

简单理解一下,如果已经启动或者调用该方法的线程不在UI线程,则需要post到UI线程中去,这里很明显,我们的调用是在UI线程中。因此,我们之间走下面的分支: mAwContents.addJavascriptInterface(obj, interfaceName);。那么,问题来了,AwContent又是什么鬼?在哪里创建的呢?

仔细查找这个类,我们发现AwContent是在initForReal方法中被创建的。而initForReal调用来自init方法。可是,init方法是在哪里调用的呢?答案是:WebView。看下面的截图:

OK,继续往下,看AwContent是怎么创建的。

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
private void initForReal() {
AwContentsStatics.setRecordFullDocument(sRecordWholeDocumentEnabledByApi
|| mAppTargetSdkVersion < Build.VERSION_CODES.LOLLIPOP);
mAwContents = new AwContents(mFactory.getBrowserContextOnUiThread(), mWebView, mContext,
new InternalAccessAdapter(), new WebViewNativeDrawGLFunctorFactory(),
mContentsClientAdapter, mWebSettings.getAwSettings(),
new AwContents.DependencyFactory() {
@Override
public AutofillProvider createAutofillProvider(
Context context, ViewGroup containerView) {
return mFactory.createAutofillProvider(context, mWebView);
}
});
if (mAppTargetSdkVersion >= Build.VERSION_CODES.KITKAT) {
// On KK and above, favicons are automatically downloaded as the method
// old apps use to enable that behavior is deprecated.
AwContents.setShouldDownloadFavicons();
}
if (mAppTargetSdkVersion < Build.VERSION_CODES.LOLLIPOP) {
// Prior to Lollipop, JavaScript objects injected via addJavascriptInterface
// were not inspectable.
mAwContents.disableJavascriptInterfacesInspection();
}
// TODO: This assumes AwContents ignores second Paint param.
mAwContents.setLayerType(mWebView.getLayerType(), null);
}

下面是一些版本兼容判断,与本文探讨主题无关,先忽略。好了,看到这里,大家是不是感觉被安卓源码忽悠的团团转,最开始我们天真地以为真正的调用来自WebView,安卓系统告诉我们来自WebViewProvider,我们以为这应该就是头了。可是现在又出现了一个AwContent。那么,它是不是真正的最终调用者呢?继续往下看:

1
2
3
4
5
6
7
8
9
10
11
12
13
/**
* @see ContentViewCore#addPossiblyUnsafeJavascriptInterface(Object, String, Class)
*/
@SuppressLint("NewApi") // JavascriptInterface requires API level 17.
public void addJavascriptInterface(Object object, String name) {
if (TRACE) Log.i(TAG, "%s addJavascriptInterface=%s", this, name);
if (isDestroyedOrNoOperation(WARN)) return;
Class<? extends Annotation> requiredAnnotation = null;
if (mAppTargetSdkVersion >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
requiredAnnotation = JavascriptInterface.class;
}
mContentViewCore.addPossiblyUnsafeJavascriptInterface(object, name, requiredAnnotation);
}

我擦,又来了一个调用对象mContentViewCore。Relax,继续往下看,看它的实现:

1
2
3
4
5
6
7
8
public void addPossiblyUnsafeJavascriptInterface(Object object, String name,
Class<? extends Annotation> requiredAnnotation) {
if (mNativeContentViewCore != 0 && object != null) {
mJavaScriptInterfaces.put(name, object);
nativeAddJavascriptInterface(mNativeContentViewCore, object, name, requiredAnnotation,
mRetainedJavaScriptObjects);
}
}

看方法名,nativeAddJavascriptInterface看起来最终调用来自于Native,继续往下看:

1
2
private native void nativeAddJavascriptInterface(int nativeContentViewCoreImpl, Object object,
String name, Class requiredAnnotation, HashSet<Object> retainedObjectSet);

接下来看C++代码,这里的中间调用过程没有深究,但最终应该是来到了这里:

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
static void AddJavascriptInterface(JNIEnv *env, jobject obj, jint nativeFramePointer,
jobject javascriptObj, jstring interfaceName)
{
#ifdef ANDROID_INSTRUMENT
TimeCounterAuto counter(TimeCounter::NativeCallbackTimeCounter);
#endif
WebCore::Frame* pFrame = 0;
if (nativeFramePointer == 0)
pFrame = GET_NATIVE_FRAME(env, obj);
else
pFrame = (WebCore::Frame*)nativeFramePointer;
LOG_ASSERT(pFrame, "nativeAddJavascriptInterface must take a valid frame pointer!");
JavaVM* vm;
env->GetJavaVM(&vm);
LOGV("::WebCore:: addJSInterface: %p", pFrame);
#if USE(JSC)
// Copied from qwebframe.cpp
JSC::JSLock lock(false);
WebCore::JSDOMWindow *window = WebCore::toJSDOMWindow(pFrame);
if (window) {
JSC::Bindings::RootObject *root = pFrame->script()->bindingRootObject();
JSC::Bindings::setJavaVM(vm);
// Add the binding to JS environment
JSC::ExecState* exec = window->globalExec();
JSC::JSObject *addedObject = WeakJavaInstance::create(javascriptObj,
root)->createRuntimeObject(exec);
const jchar* s = env->GetStringChars(interfaceName, NULL);
if (s) {
// Add the binding name to the window's table of child objects.
JSC::PutPropertySlot slot;
window->put(exec, JSC::Identifier(exec, (const UChar *)s,
env->GetStringLength(interfaceName)), addedObject, slot);
env->ReleaseStringChars(interfaceName, s);
checkException(env);
}
}
#endif // USE(JSC)
#if USE(V8)
if (pFrame) {
const char* name = JSC::Bindings::getCharactersFromJStringInEnv(env, interfaceName);
NPObject* obj = JSC::Bindings::JavaInstanceToNPObject(new JSC::Bindings::JavaInstance(javascriptObj));
pFrame->script()->bindToWindowObject(pFrame, name, obj);
// JavaInstanceToNPObject calls NPN_RetainObject on the
// returned one (see CreateV8ObjectForNPObject in V8NPObject.cpp).
// BindToWindowObject also increases obj's ref count and decrease
// the ref count when the object is not reachable from JavaScript
// side. Code here must release the reference count increased by
// JavaInstanceToNPObject.
_NPN_ReleaseObject(obj);
JSC::Bindings::releaseCharactersForJString(interfaceName, name);
}
#endif
}

这里的代码量较大,我们主要关注下面这一行代码:

1
2
window->put(exec, JSC::Identifier(exec, (const UChar *)s, 
env->GetStringLength(interfaceName)), addedObject, slot);

最终数据的处理原来来自于C++端的window对象,这又是什么呢?继续看:

1
WebCore::JSDOMWindow *window = WebCore::toJSDOMWindow(pFrame);

这是在WebCore命名空间下面的JSDOMWindow对象,看到这里,其实大多数同学应该已经都没有兴趣看下去了。这实在是一个冗长的调用过程,而且在阅读源码过程中,我们还忽略多进程调用,忽略各种细节。对此,关于这段源码的阅读,我们暂且告一段落,等时间充裕,我再来补充。

总结

这次的问题牵扯了移动端、Web前端和后台,这种跨平台的问题解决起来的确存在很大的困难。其实,我已经很长时间没有写JS了,仅仅在几个月前使用RN的时候有了解一些ES6的语法。凭借刚刚工作时仅有的2个月JS经验,加上在多方面知识的累积,总算顺利解决了问题。其实,根据我的经验来看,越是看起来无头绪的问题,往往越是一个极其简单的问题。为了避免出现这种问题,在编码过程中,必须小心翼翼。尽量多检查几次,避免出现类似这样的错误。另外,要尝试接受不一样的观点,如果你一开始就接受了其他人的观点,在解决问题上就会有很强的目的性,解决问题的速度也就更快。

最后,新的一年里,祝大家万事如意,阖家欢乐,工作顺顺利利,身体健健康康。

Kotlin 代理模式

代理模式是23种经典设计模式之一,代理模式被认为是继承的更好替代解决方案;因为代理比继承更加灵活,在Java语言中,通过反射可以实现动态代理,动态代理可以实现AOP编程,即:可以动态地往已有类中添加逻辑;比如:实现事务的自动提交,异常的自动捕获,热修复等等;

在Kotlin语言中,代理模式是默认支持的,不需要任何额外的代码,你只需要记住一个关键字by。我们不妨来试一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
interface Base {
fun sayHi()
}

class BaseImpl : Base {
override fun sayHi() {
println("BaseImpl->sayHi")
}
}
class Derived(b: Base) : Base by b

fun main(args: Array<String>) {
val b = BaseImpl()
val derived = Derived(b)
derived.sayHi()
}

这里Derived作为BaseImpl的代理类,拥有BaseImpl类中的所有方法,Derived将代理BaseImpl类执行BaseImpl类中的所有方法,就像继承自BaseImpl类一样。这样说起来有点抽象,来看一下Kotlin编译器具体为我们做了一些什么。但是,怎么看呢?教大家一个方法!
大家都知道,Kotlin和Java均是JVM语言,最终均转换到同样的Java字节码,这样我们就可以先将Kotlin编译为.class文件,再反编译为.java文件,看看对应的Java代码,我们就可以看到更多的细节。下面是最终反编译生成的Java代码:

1
2
3
4
5
6
7
8
public final class Derived implements Base {
public Derived(@NotNull Base b) {
this.$$delegate_0 = b;
}
public void sayHi() {
this.$$delegate_0.sayHi();
}
}

这里,我们可以清楚地看到,Kotlin编译器为我们动态添加了一个成员变量$$delegate_0,这个成员变量代表被代理的对象,这里对应的是BaseImpl对象,Derived里面的sayHi方法最终调用是代理对象的sayHi方法,即Kotlin编译器帮我们提供了一个非常漂亮的代理模式实现。

代理属性

在一些情况下,我们可能希望某些属性延迟加载,即在我们正在需要的时候才对它赋值;亦或者我们希望可以随时监听属性值的变化;在上述这些场景中,代理属性就可以发挥作用了。

代理属性的语法格式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class DelegateProperty {
val d: String by Delegate()
}
class Delegate {
operator fun getValue(thisRef: Any? , property: KProperty<*>): String {
return "Invoke getValue() , thisRef = $thisRef , property name = ${property.name}"
}
operator fun setValue(thisRef: Any? , property: KProperty<*> , value: String) {
println("Invoke setValue() , thisRef = $thisRef , property name = ${property.name} , value = $value")
}
}

fun main(args: Array<String>) {
val dp = DelegatedProperty()
dp.d = "Value0" // Invoke setValue() , thisRef = DelegatedProperty@2ef1e4fa , property name = d , value = Value0

println(dp.d) // Invoke getValue() , thisRef = DelegatedProperty@2ef1e4fa , property name = d
}

这里的代理是如何实现的呢?我们知道,Kotlin的属性值会自动生成set/get方法,而代理类通过代理set/get方法生成相应的代理方法,这里的方法对应关系如下:

1
2
3
4
// thisRef对应代理对象的引用,property对应代理属性的反射属性封装
// 注意这里的代理方法一定要添加operator关键字,operator关键字是重载操作符关键字,后续的文章中会讲到,敬请期待
get() -> operator fun getValue(thisRef: Any? , property: KProperty<*>)
set() -> operator fun setValue(thisRef: Any? , property: KProperty<*> , value: T)

Kotlin标准库提供了一些常用代理的方法实现,即上文提到的几种代理,先来看第一种:延迟加载。

延迟加载

Kotlin提供了一个lazy方法用于实现延迟加载,lazy方法有一个lambda表达式参数,用于对属性进行初始化赋值,而一旦完成赋值,该lambda表达式将不会再次调用。lambda表达式调用发生在第一次使用该属性的时候,即实现了属性赋值的延迟加载。来看一个简单的例子:

1
2
3
4
5
6
7
8
9
10
11
// 使用标准库实现的lazy函数,实现属性的延迟加载
private val lazyValue: String by lazy {
println("调用该初始赋值表达式完成赋值")
// 这里是实际赋值
"Hello, world"
}
fun main(args: Array<String>) {
// 仅在第一次会调用lazy方法的lambda表达式
println(lazyValue) // 打印:调用该初始赋值表达式完成赋值
println(lazyValue) // 打印: Hello, world, 再次调用将不再调用lambda表达式
}

lazy方法是一个线程安全的延迟加载方法,为了加深大家的理解,根据上面的原理,我们尝试自己来实现一个非线程安全的延迟加载方法,看具体实现:

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
private object UNINITIALIZE_VALUE

class MyLazy<T>(initialize: ()->T) {
private var value: Any? = UNINITIALIZE_VALUE
private val initialize = initialize
operator fun getValue(thisRef: Any? , property: KProperty<*>): T {
if(value == UNINITIALIZE_VALUE) {
value = initialize()
}
return value as T
}
operator fun setValue(thisRef: Any? , property: KProperty<*> , value: T) {
this.value = value
}
}
// 为了和标准库区分,使用__lazy命名
fun <T> __lazy(initialize: () -> T): MyLazy<T> = MyLazy(initialize)

var lazyValue1 by __lazy {
println("自定义lazy初始化赋值表达式被调用")
"Hello , world"
}

fun main(args: Array<String>) {
// 自定义延迟加载函数__lazy
println(lazyValue1)
lazyValue1 = "Other value"
println(lazyValue1)
}

由此可见,实现一个延迟加载接口并不复杂,最重要的是要理解延迟加载的过程以及实现原理。总结实现延迟加载接口,需要注意三个地方:

  • 需要提供初始化lambda表达式参数,用于初始赋值
  • 需要实现代理属性对象的setValue/getValue方法,如果是val则只需要实现getValue即可
  • 需要严格确保属性不会被多次初始化

Observable属性

Kotlin标准库还提供了一个可观察属性,这个属性使用观察者模式实现,如果属性值发生变化则会调用相应的回调lambda接口通知使用者,先看一个具体的例子:

1
2
3
4
5
6
7
8
9
var observableValue by Delegates.observable("Initial value") {  prop , old , new ->
println("$old -> $new")
}

fun main(args: Array<String>) {
println(observableValue) // 打印:Initial value
observableValue = "Hello" // 打印: Initial value -> Hello
println(observableValue) // 打印:Hello
}

这里的具体实现,感兴趣的同学请参看文章开头的方法进行追踪!

Storing Properties in a Map

这也是Kotlin标准库提供的一个非常有用的特性,它主要用于JSON数据的解析。看官方的例子:

1
2
3
4
5
6
7
8
9
class User(val map: Map<String, Any?>) {
val name: String by map
val age: Int by map
}

val user = User(mapOf(
"name" to "John Doe",
"age" to 25
))

该方法比较简单,这里就不再赘述了!

总结

至此,关于代理的介绍可以暂时告一段落了!
代理模式是一个非常经典设计模式,在解决某些问题中可以发挥事半功倍的效果。幸运的是,Kotlin语言原生支持代理模式,实现代理模式如同声明一个属性一样简单。而且,代理模式的设计也非常漂亮,仅仅使用一个关键字by极尽简约之美。在日常编码中,一定要灵活运用代理模式,比如实现延迟加载,实现属性观察等等。KotterKnife 是一个非常经典的代理模式的实现例子,有兴趣的同学可以clone该仓库,查看源码,领会代理模式的优美。

欢迎加入Kotlin交流群

如果你也喜欢Kotlin语言,欢迎加入我的Kotlin交流群: 329673958 ,一起来参与Kotlin语言的推广工作。

文章源码地址

Kotliner: https://github.com/yuanhoujun/Kotliner,
别忘了点击仓库右上方的star哦!

Your browser is out-of-date!

Update your browser to view this website correctly. Update my browser now

×