从 Java 迁移到 Kotlin:关键应用全部切换、代码库千万行 Kotlin 代码

作者 | 罗燕珊、核子可乐

Facebook 母公司 Meta 正在将其 Android 应用的 Java 代码迁移到 Kotlin。根据 Meta 的官方博客所述,截至今天,其 Android 代码库已经有超过 1000 万行 Kotlin 代码,旗下包括 Facebook、Instagram、Messenger、Portal 和 Quest 在内的应用都已经开始从 Java 转向 Kotlin。

Kotlin 是一种更年轻的编程语言,也依赖于 Java 虚拟机。Kotlin 由软件工具制造商 JetBrains 创建,于 2011 年首次亮相,2016 年发布 1.0 版本。次年,它被 Google 采用为 Android 开发的一级语言,并由其基金会管理,该基金会由 JetBrains 和 Google 资助。

到 2019 的 Google I/O 大会,Google 正式宣布,Kotlin 编程语言已成为 Android 应用开发人员的首选语言,并在当年年底表示前 1000 个 Android 应用程序中有近 60% 包含 Kotlin 代码。

从 Google 自身来看,明面上它说自己选择 Kotlin 的理由是它更简洁、更安全、支持结构化并发,能更轻松地编写异步代码,并且可以与 Java 互操作。不过,另一个业界推测是可能跟那宗与 Oracle 旷日持久的 Java 侵权案有关—— Oracle 花了十多年的时间追究 Google 在 Android 中使用 Java API 的侵权索赔,最终 Oracle 败诉。

回到 Meta,Facebook 软件工程师 Omer Strulovich 对选择 Kotlin 如此解释道:“Kotlin 通常被认为是一种比 Java 更好的语言,在年度 Stack Overflow 开发人员调查中,其受欢迎程度高于 Java,”他还指出,由于近年来 Kotlin 已成为 Android 开发的流行语言,“因此,在努力使我们的开发工作流程更加高效的过程中,我们在 Meta 的安卓开发中转向 Kotlin 是非常合理的……”

除了受欢迎之外,Meta 认为 Kotlin 拥有的主要优势包括可空性、函数式编程、代码更短、以及领域特定语言(DSL)等等。

不过,Strulovich 指出,过渡到 Kotlin 也有一些不可忽视的缺点,比如混合代码库可能难以维护,以及 Kotlin 虽然流行,但与 Java 相比还是有比较大的差距,工具集还不够成熟。所有 Kotlin 工具都需要考虑 Kotlin 和 Java 的互操作性,这使得它们的实现变得复杂。

但 Meta 最大的担忧还是构建时间。“我们从一开始就知道 Kotlin 的构建时间会比 Java 的要长。该语言及其生态系统更加复杂,Java 在优化其编译器方面领先了 20 年。由于我们拥有多个大型应用程序,较长的构建时间可能会对我们的开发人员体验产生负面影响。”

Strulovich 没有透露 Meta 何时开始这种转变。Meta 本来可以选择只用 Kotlin 编写新代码,但它最终还是决定将所有的 Android 应用程序都转换过来。

根据 Strulovich 的说法,如果是只使用 Kotlin 来编写新代码,继续保留大部分现有 Java 代码的话,工作量明显更低,但相应的也有两个缺点:首先就是要在 Kotlin 和 Java 代码之间实现互操作性,就需要引入 Kotlin 中的 platform 类型。Platform 类型会导致运行时中的空指针取消引用,进而引发崩溃,这就破坏了纯 Kotlin 代码提供的静态安全优势。在某些复杂情况下,Kotlin 的空检查省略可能会漏掉空值,意外引发空指针异常。例如,如果 Kotlin 代码调用由 Java 接口实现的 Kotlin 接口,就会发生这种情况。其他的问题还包括 Java 无法将类型参数标记为可空(最近才刚刚修复);Kotlin 的重载规则考虑到了可空性,Java 的重载规则却没有考虑到。

第二个缺点是,这种方式要求对 Meta 已经开发的大多数软件进行代码修改。如果继续把大部分代码保留为 Java 形式,那开发人员就没法充分发挥 Kotlin 的优势。

如今,Meta 旗下的 Android 版 Facebook、Messenger 和 Instagram 应用都拥有超过百万行 Kotlin 代码,而且转换率也一路走高。纵观整个 Android 代码库,其中的 Kotlin 代码量已经超过千万行。

事实上,在尝试为现有应用程序引入 Kotlin 时,Meta 遇到了不少麻烦。例如,团队得更新 Redex 才能支持 Java 无法生成的字节码模式。另外,其使用的某些内部库要求在编译期间进行字节码转换来获取更好的性能。而在将这些库纳入 Kotlin 编译过程时,这部分代码无法正常起效。为此,Meta 针对这些问题构建了专门的解决工具。

Meta 还发现,现有工具之间存在不少冲突。例如,代码审查和 wiki 工具无法对 Kotlin 语法进行高亮显示。“我们还更新了之前使用的 Pygments 库,确保其体验与处理 Java 代码时一致。我们更新了一些内部代码修改工具,使其能够支持 Kotlin。我们也构建了 Ktfmt,一款基于 google-java-format 编码理念的确定性 Kotlin 格式化程序。”

在工具准备齐全之后,Meta 现在已经能将代码中的任意部分转换为 Kotlin。但每次迁移都需要大量样板设计工作,只能由员工们手动完成。J2K 是一种通用工具,并不会去理解所转换的代码是在表达什么。因此,某些特定部分就只能进行手动调整。

最典型的例子就是 Junit 测试规则的使用。假设使用 ExpectedException 规则,来验证是否抛出了正确的异常:

当 J2K 将这部分代码转换成 Kotlin 时,得到的就是:

这段代码乍看之下与原先的 Java 代码等价,但由于 Kotlin 使用了 site 注解,所以其实际上等价于:

尝试运行后,此测试会失败并返回一个错误:“The @Rule expectedException must be public”,这是因为 Junit 发现了一条带有 @Rule 注解的私有字段。这是个常见问题,论坛上面也已经有成熟答案:要么在字段中添加“@JvmField”;要么在注解中添加注解 use-site,也就是“@get:Rule”:

由于 J2K 无法(可能也不应该)感知 JUnit 的复杂性,所以没能正确完成转换。但即使 JUnit 不存在这个问题,J2K 在处理其他小众框架的时候也肯定会掉类似的坑。

例如,很多 Android Java 代码会使用 android.text.TextUtils 中的实用方法,例如 isEmpty,来简化对某些字符串的检查。但在 Kotlin 中,其实是有内置的标准库方法 String.isNullOrEmpty 的。该方法之所以更好,是因为它能通过契约来告知 Kotlin 编译器如果它返回 false,则被测试的对象不得再为 null,并将其智能转换为 String。

Java 代码也有不少类似的辅助方法,也有很多库都实现了相同的基本方法。这一切都需要替换成标准的 Kotlin 方法,借此简化代码并保证编译器能正确检测出不可为空的类型。

Strulovich 表示,内部发现了许许多多类似的小小修复实例。有些难度不大(例如替换 isEmpty),有些则需要研究一番才能搞明白(例如 JUnit 规则)。还有一些其实属于 J2K 出的错,可能导致构建错误、运行时行为错乱等问题。

为了解决这些问题,Meta 团队将 J2K 转换流程划分成三个步骤:

  1. 首先,取一个 Java 包并准备将其转换为 Kotlin。这个步骤主要解决错误,并完成相应的内部工具转换。
  2. 第二步就是运行 J2K。团队已经能够以无头模式运行 Android Studio 并调用 J2K,由此将整个管道作为脚本来运行。
  3. 最后一步,对新的 Kotlin 文件进行后处理。具体包括大部分自动重构与修复步骤,例如将 JUnit 规则标记为 @JvmField。在此步骤中,团队还应用了自动更新 linter,并在无头模式下应用各种 Android Studio 建议。“当然,自动化并不足以解决所有问题,但至少能帮我们优先处理那些最常见的问题。”Strulovich 说。

在 Java 重构方面,Meta 使用的是 JavaASTParser 等工具,它能帮助解析某些类型。而在 Kotlin 这边,团队还没有找到能够解析类型的好办法,所以选择使用 Kotlin 编译器 API。

Meta 还发布了一组自动重构方法(https://github.com/fbsamples/kotlin_ast_tools)。虽然不是很多,但希望能帮助更多开发者利用 Kotlin 编译器解析器高效完成工作。

平均而言,Meta 发现迁移后的代码行数减少了 11%。尽管网上各种案例引用的数字往往要比这高得多,但他们还是对这个数字感到满意。

Strulovich 说,Meta 向 Kotlin 的迁移仍在进行中并在加速。“Kotlin 仍然缺乏一些我们在使用 Java 时已经习惯了的工具和优化,但我们正在努力缩小这些差距。随着我们取得的进展和这些工具和库的成熟,我们也将努力把它们反馈给社区。”

参考链接:

https://www.theregister.com/2022/10/25/meta_java_kotlin/

https://engineering.fb.com/2022/10/24/android/android-java-kotlin-migration/

JSON 字符串是如何被解析的?JsonParser了解一下

  • Jackson 版本:2.11.0
  • Spring Framework 版本:5.2.6.RELEASE
  • Spring Boot 版本:2.3.0.RELEASE

什么叫读 JSON?就是把一个 JSON 字符串 解析为对象 or 树模型嘛,因此也称作解析 JSON 串。Jackson 底层流式 API 使用JsonParser来完成JSON 字符串的解析。

准备一个 POJO:

测试用例:把一个 JSON 字符串绑定(封装)进一个 POJO 对象里

运行程序,输出:

成功把一个 JSON 字符串的值解析到 Person 对象。你可能会疑问,怎么这么麻烦?那当然,这是底层流式 API,纯手动档嘛。你获得了性能,可不要失去一些便捷性嘛。

小贴士:底层流式 API 一般面向“专业人士”,应用级开发使用高阶 API ObjectMapper即可。当然,读完本系列就能让你完全具备“专业人士”的实力

JsonParser针对不同的 value 类型,提供了非常多的方法用于实际值的获取。

直接值获取:

这类方法可能会抛出异常:比如 value 值本不是数字但你调用了 getInValue()方法~

小贴士:如果 value 值是 null,像 getIntValue()、getBooleanValue()等这种直接获取方法是会抛出异常的,但 getText()不会

带默认值的值获取,具有更好安全性:

此类方法若碰到数据的转换失败时,不会抛出异常,把def作为默认值返回。

同JsonGenerator一样,JsonParser 也提供了高钙片组合方法,让你更加便捷的使用。

听起来像高级功能,是的,它必须依赖于ObjectCodec去实现,因为实际是全部委托给了它去完成的,也就是我们最为熟悉的 readXXX 系列方法:

我们知道,ObjectMapper 就是一个 ObjectCodec,它属于高级 API,本文显然不会用到 ObjectMapper 它喽,因此我们自己手敲一个实现来完成此功能。

自定义一个 ObjectCodec,Person 类专用:用于把 JSON 串自动绑定到实例属性。

有了它,就可以实现我们的自动绑定了,书写测试用例:

运行程序,输出:

这就是 ObjectMapper 自动绑定的核心原理所在,其它更为强大能力将在后续章节详细展开。

在上例解析过程中,有一个非常重要的角色,那便是:JsonToken。它表示解析 JSON 内容时,用于返回结果的基本标记类型的枚举。

为了辅助理解,A 哥用一个例子,输出各个部分一目了然:

运行程序,输出:

从左至右解析,一一对应。各个部分用下面这张图可以简略表示出来:

小贴士:解析时请确保你的的 JSON 串是合法的,否则抛出JsonParseException异常

它是 JsonParser 的一个内部枚举类,共 15 个枚举值:

小贴士:枚举值均为 bool 类型,括号内为默认值

每个枚举值都控制着JsonParser不同的行为。下面分类进行解释

自 2.10 版本后,使用StreamReadFeature#AUTO_CLOSE_SOURCE代替

Jackson 的流式 API 指的是 I/O 流,所以即使是,底层也是用 I/O 流(Reader)去读取然后解析的。

原理和 JsonGenerator 的AUTO_CLOSE_TARGET(true)一样,不再解释。

JSON 是有规范的,在它的规范里并没有描述到对注释的规定、对控制字符的处理等等,也就是说这些均属于非标准行为。比如这个 JSON 串:

你看,若你这么写 IDEA 都会飘红提示你:

但是,在很多使用场景(特别是 JavaScript)里,我们会在 JSON 串里写注释(属性多时尤甚)那么对于这种串,JsonParser 如何控制处理呢?它提供了对非标准 JSON 格式的兼容,通过下面这些特征值来控制。

自 2.10 版本后,使用JsonReadFeature#ALLOW_JAVA_COMMENTS代替

是否允许/* */或者//这种类型的注释出现。

运行程序,抛出异常:

放开注释的代码,再次运行程序,正常 work

自 2.10 版本后,使用JsonReadFeature#ALLOW_YAML_COMMENTS代替

顾名思义,开启后将支持 Yaml 格式的的注释,也就是#形式的注释语法。

自 2.10 版本后,使用JsonReadFeature#ALLOW_UNQUOTED_FIELD_NAMES代替

是否允许属性名不带双引号\”\”,比较简单,示例略。

自 2.10 版本后,使用JsonReadFeature#ALLOW_SINGLE_QUOTES代替

是否允许属性名支持单引号,也就是使用\’\’包裹,形如这样:

自 2.10 版本后,使用JsonReadFeature#ALLOW_UNESCAPED_CONTROL_CHARS代替

是否允许 JSON 字符串包含非引号控制字符(值小于 32 的 ASCII 字符,包含制表符和换行符)。 由于 JSON 规范要求对所有控制字符使用引号,这是一个非标准的特性,因此默认禁用。

那么,哪些字符属于控制字符呢?做个简单科普:我们一般说的 ASCII 码共 128 个字符(7bit),共分为两大类

控制字符,也叫不可打印字符。第0~32 号及第 127 号(共 34 个)是控制字符,例如常见的:LF(换行)CR(回车)、FF(换页)、DEL(删除)、BS(退格)等都属于此类。

控制字符大部分已经废弃不用了,它们的用途主要是用来操控已经处理过的文字,ASCII 值为 8、9、10 和 13 分别转换为退格、制表、换行和回车字符。它们并没有特定的图形显示,但会依不同的应用程序,而对文本显示有不同的影响。

话外音:你看不见我,但我对你影响还蛮大

也叫可显示字符,或者可打印字符,能从键盘直接输入的字符。比如 0-9 数字,逗号、分号这些等等。

话外音:你肉眼能看到的字符就属于非控制字符

自 2.10 版本后,使用JsonReadFeature#ALLOW_BACKSLASH_ESCAPING_ANY_CHARACTER代替

是否允许反斜杠转义任何字符。这句话不是非常好理解,看下面这个例子:

运行程序,报错:

放开注释掉的代码,再次运行程序,一切正常,输出:YourB\’atman。

自 2.10 版本后,使用JsonReadFeature#ALLOW_LEADING_ZEROS_FOR_NUMBERS代替

是否允许像00001这样的“数字”出现(而不报错)。看例子:

运行程序,输出:

放开注掉的代码,再次运行程序,一切正常。输出18。

自 2.10 版本后,使用JsonReadFeature#ALLOW_LEADING_DECIMAL_POINT_FOR_NUMBERS代替

是否允许小数点.打头,也就是说.1这种小数格式是否合法。默认是不合法的,需要开启此特征才能支持,例子就略了,基本同上。

自 2.10 版本后,使用JsonReadFeature#ALLOW_NON_NUMERIC_NUMBERS代替

是否允许一些解析器识别一组“非数字”(如 NaN)作为合法的浮点数值。这个属性和上篇文章的JsonGenerator#QUOTE_NON_NUMERIC_NUMBERS特征值是遥相呼应的。

运行程序,抛错:

放开注释掉的代码,再次运行,一切正常。输出:

小贴士:NaN 也可以表示一个 Float 对象,是的你没听错,即使它不是数字但它也是 Float 类型。具体你可以看看 Float 源码里的那几个常量

自 2.10 版本后,使用JsonReadFeature#ALLOW_MISSING_VALUES代替

是否允许支持JSON 数组中“缺失”值。怎么理解:数组中缺失了值表示两个逗号之间,啥都没有,形如这样[value1, , value3]。

运行程序,抛错:

放开注释掉的代码,再次运行,一切正常,结果为:

请注意:此时数组的长度是 5 哦。

小贴士:此处用的 String 类型展示结果,是因为 null 可以作为 String 类型(jsonParser.getText()得到 null 是合法的)。但如果你使用的 int 类型(或者 bool 类型),那么如果是 null 的话就报错喽Current token (VALUE_NULL) not of boolean type,有兴趣的亲可自行尝试,巩固下理解的效果。报错原因文上已有说明~

自 2.10 版本后,使用JsonReadFeature#ALLOW_TRAILING_COMMA代替

是否允许最后一个多余的逗号(一定是最后一个)。这个特征是非常重要的,若开关打开,有如下效果:

  • [true,true,]等价于[true, true]
  • {\”a\”: true,}等价于{\”a\”: true}

当这个特征和上面的ALLOW_MISSING_VALUES特征同时使用时,本特征优先级更高。也就是说:会先去除掉最后一个逗号后,再进行数组长度的计算。

举个例子:当然这两个特征开关都打开时,[true,true,]等价于[true, true]好理解;并且呢,[true,true,,]是等价于[true, true, null]的哦,可千万别忽略最后的这个 null

运行程序,输出:

这完全就是上例的效果嘛。现在我放开注释掉的代码,再次运行,结果为:

请注意对比前后的结果差异,并自己能能自己合理解释

Jackson 在 JSON 标准之外,给出了两个校验相关的特征。

自 2.10 版本后,使用StreamReadFeature#STRICT_DUPLICATE_DETECTION代替

是否允许 JSON 串有两个相同的属性 key,默认是允许的

运行程序,正常输出:

若放开注释代码,再次运行,则抛错:

自 2.10 版本后,使用StreamReadFeature#IGNORE_UNDEFINED代替

是否忽略没有定义的属性 key。和JsonGenerator.Feature#IGNORE_UNKNOWN的这个特征一样,它作用于预先定义了格式的数据类型,如Avro、protobuf等等,JSON 是不需要预先定义的哦~

同样的,你可以通过这个 API 预先设置格式:

自 2.10 版本后,使用StreamReadFeature#INCLUDE_SOURCE_IN_LOCATION代替

是否构建JsonLocation对象来表示每个 part 的来源,你可以通过JsonParser#getCurrentLocation()来访问。作用不大,就此略过。

本文介绍了底层流式 API JsonParser 读 JSON 的方式,它不仅仅能够处理标准 JSON,也能通过 Feature 特征值来控制,开启对一些非标准但又比较常用的 JSON 串的支持,这不正式一个优秀框架/库应有的态度麽:兼容性

结合上篇文章对写 JSON 时JsonGenerator的描述,能够总结出两点原则:

  • 写:100%遵循规范
  • 读:最大程度兼容并包

写代表你的输出,遵循规范的输出能确保第三方在用你输出的数据时不至于对你破口大骂,所以这是你应该做好的本分。读代表你的输入,能够处理规范的格式是你的职责,但我若还能额外的处理一些非标准格式(一般为常用的),那绝对是闪耀点,也就是你给的情分。本分是你应该做的,而情分就是你的加分项

本文作者及来源:Renderbus瑞云渲染农场https://www.renderbus.com

点赞 0
收藏 0

文章为作者独立观点不代本网立场,未经允许不得转载。