引子
当代码的运行效果不符合预期时就得进行调试,排查下整个数据链路上到底是哪个环节出了问题。
断点调试当然是首选,因为它可以单步执行程序,并查看当前执行步骤中所有的数据值。但有些场景下,断点调试就显得笨拙。比如大量异步并发的场景,当程序不是线性执行而是跳来跳去时,就会发生你期望下一步是执行到这里,断点调试却跳到了另一个线程,这样的复杂度,让正在执行的代码变得难以理解。除此之外,有些型号的手机,一断点调试就卡的不行,甚至 crash。
在这种情况下,打日志就是唯一的选择了。
日志输出一个简单的变量值是一件轻而易举的事情:
Log.v("test", "duration=${duration}")
但在复杂的业务场景中,会存在各种嵌套的复杂结构,比如 List,Map。断点调试中,可以轻松地点开这些数据结构,查看任何感兴趣的字段,甚至还可以当场计算。在输出日志时有什么好办法能够轻松地输出这些复杂的数据结构吗?
打印列表 & Map
刚开始开发 Android 时,我是这样打印列表的:
for (int i = 0; i < list.size(); i++) { Log.d("test", "list item="+list.get(i)); }
用一个 for 循环来打印列表所有元素。
后来我学会了用更高级的语法来简化日志输出:
for (String str:list) { Log.v("test", "list item="+str); }
这样的写法是无法被复用的,因为不同的业务场景,数据类型都不一样。为了调试,这样的 for 循环就会散落在各处。
这样写还有一个坏处,输出的列表信息可能被其他日志穿插。因为每一个列表内容都是一条新得日志,中间极有可能被别的 log 打断。
有没有一个函数可以打印包含任意数据类型的列表,并将列表内容组织成更具可读性的字符串?
用 Kotlin 的扩展函数
+泛型
+高阶函数
就能优雅地做到:
fun <T> Collection<T>.print(mapper: (T) -> String) = StringBuilder("\n[").also { sb -> //遍历集合元素将元素转换成感兴趣的字串,并独占一行 this.forEach { e -> sb.append("\n\t${mapper(e)},") } sb.append("\n]") }.toString()
为集合的基类Collection
新增一个扩展函数,它是一个高阶函数,因为它的参数是另一个函数,该函数用 lambda 表示。再把集合元素抽象成泛型。通过StringBuilder
将所有集合内容拼接成一个自动换行的字符串。
写段测试代码看下效果:
data class Person(var name: String, var age: Int) val persons = listOf( Person("Peter", 16), Person("Anna", 28), Person("Anna", 23), Person("Sonya", 39) ) persons.print { "${it.name}_${it.age}" }.let { Log.v("test",it) }
打印结果如下:
V/test: [ Peter_16, Anna_28, Anna_23, Sonya_39, ]
这样整个列表内容会作为一条log输出。
同样地,可以如法炮制一个打印 Map 的扩展函数:
fun <K, V> Map<K, V?>.print(mapper: (V?) -> String): String = StringBuilder("\n{").also { sb -> this.iterator().forEach { entry -> sb.append("\n\t[${entry.key}] = ${mapper(entry.value)}") } sb.append("\n}") }.toString()
打印复杂数据结构
有些数据类字段比较多,调试时,想把它们通通打印出来,在 Java 中,借助于 AndroidStudio 的 toString
功能倒是可以方便地生成可读性很高的字串:
public class Person { private String name; private int age; @Override public String toString() { return ”Person{“ + ”name=‘“ + name + ’\” + ”, age=“ + age + ‘}’; } }
但是每新建一个数据类都要手动生成一个toString()
方法也挺麻烦。
利用 Kotlin 的 data class
可以省去这一步,但打印效果是所有字段都在同一行中:
data class Person(var name: String, var age: Int) Log.v(“test”, “person=${Person("Peter", 16)}”) //输出如下: V/test: person=Person(name=Peter, age=16)
如果字段很多,把它们都打印在一行中可读性很差。
有没有一种方法,可以读取一个类中所有的字段信息?
这样我们就可以将他们组织成想要的形状。
请看下面这个方法:
fun Any.ofMap() = // 过滤掉除data class以外的其他类 this::class.takeIf { it.isData } // 遍历类的所有成员 过滤掉成员方法 只考虑成员属性 ?.members?.filterIsInstance<KProperty<Any>>() // 将成员属性名和值存储在Pair中 ?.map { it.name to it.call(this) } // 将Pair转换成map ?.toMap()
为任意 Kotlin 中的类添加一个扩展函数
,它的功能是将data class
中所有的字段名及其对应值存在一个 map 中。其中用到的 Kotlin 语法糖如下:
isData
是KClass
中的一个属性,用于判断该类是不是一个data class
。KClass
是 Kotlin 中用来描述 类的类型,KClass
可以通过对象::class
语法获得。
members
也是KClass
中的一个属性,它以列表的形式返回了类中所有的方法和属性。
filterIsInstance()
是Iterable
接口的扩展函数,用于过滤出集合中指定的类型。
to
是一个infix
扩展函数,它的定义如下:
public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)
- 带有
infix
标识的函数只允许带有一个参数,并且在调用时可以省略包裹参数的括号。这种语法叫中缀表达式
,它用于简化方法调用。
写段测试代码,结合上一节的打印 map 函数看下效果:
data class Person(var name: String, var age: Int) Person("Peter", 16).ofMap()?.print { it.toString() }.let { Log.v("test","$it") }
测试代码先将Person
实例转换成 map,然后打印 map。输出结果如下:
V/test: { [age] = 16 [name] = Peter }
若data class
嵌套会发生什么?
//位置,嵌套在Person类中 data class Location(var x: Int, var y: Int) data class Person( var name: String, var age: Int, var locaton: Location? = null ) Person("Peter", 16, Location(20, 30)) .ofMap() ?.print { it.toString() } .let { Log.v("test", "$it") } // 打印结果如下 { [age] = 16 [locaton] = Location(x=20, y=30) [name] = Peter }
期望得到类似 Json 的打印效果,但输出结果还差一点。是因为将Person
转化成Map
时并没有将嵌套的Location
也转化成键值对。
需要将ofMap()
方法重构成递归调用:
fun Any.ofMap(): Map<String, Any?>? { return this::class.takeIf { it.isData } ?.members?.filterIsInstance<KProperty<Any>>() ?.map { member -> val value = member.call(this)?.let { v-> //'若成员变量是data class,则递归调用ofMap(),将其转化成键值对,否则直接返回值' if (v::class.isData) v.ofMap() else v } member.name to value } ?.toMap() }
为了让打印结果也有嵌套缩进效果,打印 Map 的函数也需要相应地重构:
/** * 打印 Map,生成结构化键值对子串 * @param space 行缩进量 */ fun <K, V> Map<K, V?>.print(space: Int = 0): String { //'生成当前层次的行缩进,用space个空格表示,当前层次每一行内容都需要带上缩进' val indent = StringBuilder().apply { repeat(space) { append(" ") } }.toString() return StringBuilder("\n${indent}{").also { sb -> this.iterator().forEach { entry -> //'如果值是 Map 类型,则递归调用print()生成其结构化键值对子串,否则返回值本身' val value = entry.value.let { v -> (v as? Map<*, *>)?.print("${indent}${entry.key} = ".length) ?: v.toString() } sb.append("\n\t${indent}[${entry.key}] = $value,") } sb.append("\n${indent}}") }.toString() }
写段测试代码,看看效果:
//'坐标类,嵌套在Location类中' data class Coordinate(var x: Int, var y: Int) //'位置类,嵌套在Person类中' data class Location( var country: String, var city: String, var coordinate: Coordinate ) data class Person( var name: String, var age: Int, var locaton: Location? = null ) Person("Peter", 16, Location("china", "shanghai", Coordinate(10, 20))) .ofMap() ?.print() .let { Log.v("test", "$it") } //'打印效果如下' { [age] = 16, [locaton] = { [city] = shanghai, [coordinate] = { [x] = 10, [y] = 20, }, [country] = china, }, [name] = Peter, }