
2019.3.12:修改第三部分 1. 引言 现实工作中经常可以听到一些言论:框架的升级带来协议性能的提升、编程模式的变革带来业务的飞跃...... 姑且不论这些表述是否有问题,实际上如果系统地看待事物,可能会有不太一样的发现。以LINUX为例,尽管其内核大获成功,但如果不是遵循POSIX、并成为一个开源、精简的UNIX实现,很难想象其最终会有何种发展。因此,对事物进行全局探究有时会有更多启发。本文将从这十年我们熟知的Android系统开始,立足于笔者视角,阐述其意义,并结合自身团队进行思考。 2. Android设计的现实意义 架构的工程意义在于:定义并解决一类问题、为需求到实现的平稳过渡提供保障。传统意义的Android架构(图1)已被人熟知。但不同角色的视角各有不同,例如认为Runtime和框架是其核心、或者将Android看做是一种特异性JVM平台、还有从嵌入式出发将其看做是Linux……但实际上,Android是极少数几个用设计来解决自身发展问题的系统,其核心在于通过硬件抽象、组件化、接口层三种能力来为发展提供基础,并为诸多变数预留大量可操作、斡旋的空间。 图1. Android传统架构2.1 发展的前提-硬件抽象 2008年,我国迈入3G时代前夜,基础设施的变革带来充满变数的移动领域,无论设备、硬件还是软件的形态均未定型;擅长架构和软件的Google在这一领域要获得生存和长足发展,需要团结一切可能的、甚至是未知的力量。取得移动运营商、芯片供应商、手机制造商的支持则是生存的第一步。 硬件抽象层(HAL)在一定程度上起到这样的目的:它为移动领域五花八门、标准不统一的硬件驱动定义标准接口,避免Android过分依赖Linux,让后续的扩展和整机集成更为高效,满足了手机制造商的重要诉求;同时还起到隔离Linux内核的作用,避免厂商充满硬件秘密的驱动源码受GPL协议影响而开源,保障了芯片等硬件制造商的核心利益。 传统手机OS的定制和集成流程需要修改大量代码,负担不少,从这个角度来看Android HAL其设计是领先的。结合AOSP优良的代码分支、模块管理,加上基于GNU automake巨集形成的Android build system,厂商享受到超越以往的便捷。然而HAL并无固定做法(如图2所示),Android 8.0之前,最初大量采用HAL旧版方式,表现为framework直接加载*.so并依赖,主要集中在网络、蓝牙等模块;旧版方式导致framework与具体驱动接口耦合过紧,后来形成HAL传统方式,即提供一定规范和接口进行改进,从而减少直接耦合,但每次厂商支持新版Android依旧需要更改不少代码;为更有效解决这一问题,Android 8.0开启Treble项目,于是芯片厂商通过基于Binder的HIDL提供稳定接口,制造商则可不受芯片厂商影响而直接更新Framework,甚至获得无需重新编译HAL即可OTA的能力。 图2. Android对硬件驱动的设计 受益于HAL设计和其改进,Google在全球获得更广泛的支撑,尤其是Android 8.0在国内厂商的迅速适配可见一斑。HAL为Android设备量的持续增长打下基础,并促进有实力的厂商向设备上层以及基础设施两个领域进一步纵深发展(图3)。无论是掌握核心技术的厂商(如高通、华为、MTK),通过挟5G标准支持及端侧设备系统能力不断加强与上层APP联动,还是具备渠道和资源整合优势的手机制造商(华为、OPPO、小米、VIVO等),立足OS持续构建应用层面的能力版图,都体现出Android HAL设计对整个产业凝聚和影响,间接弥补Android自身的诸多不足。 图3. 具备核心竞争力的厂商的发展趋势2.2 能力的枢纽-组件化 对能力进行如何组织和复用是架构的最大挑战,借鉴现有能力是发展的捷径。无论是Mircosoft的COM,还是OMG的CORBA,或是从EJB到Spring、从SOA到Serverless,随着基础设施如网络、终端设备的能力提升,这些技术的发展呈现出从重量到轻量、从对中心(总线)的重度依赖到轻量级依赖的趋势。Android充分结合各领域先进技术,并基于移动端资源受限这一最大特色,形成了自身的技术特色:AIDL衍生自复杂的CORBA IDL,组件由SOA精简而来,各独立生老病死的System Service类似一个个微服务,Binder可以看做是对一种弱化总线、性能更好、可点对点通信的DBUS,UI布局系统则极大程度受到SWING的影响、manifest实际上就是APP与系统通信所必须的组件接口描述文件...... 上面提到的这些领域技术的确有利于Android发展,但远远不够。回想之前谈到的HAL以及整体架构,我们看到Android实际上就是个大杂烩,使用的是诸多技术的混合。过去除PalmOS外,无论是基于Linux/Unix构建的系统如Meego,还是Symbian、MTK、UCOS、WindowsCE,无论是实时系统还是非实时系统,这些移动端系统都以C/C++为主且小巧精悍,对内存使用和要求极为考究,虽然满足了资源受限设备的使用诉求但带来了门槛;虚拟机类的平台如KJava、.NET on Windows Phone虽然内存使用和能耗方面比较大方,却胜在研发效率和容错性,因而受到不少开发者欢迎。所以选择混合架构对于缺乏完整移动领域产业链支撑的Google既符合其自身技术理念、又胜算最大,所以量身定制的组件化能力便肩负起这一使命,使得各组件得到有机组合、应用之间以及应用和系统的沟通更为明确和有约束,最终帮助整个系统灵活运转,能力被迅速放大。 观察Android系统的启动运行流程(图4)以及APP对系统能力的使用(图5),可以发现其上各类能力已按照组件化标准和粒度进行组织(能力的注册发现、接口和通信的标准化、运行空间的隔离等),让快速迭代的手机硬件和持续升级的系统能力以最小代价透出,将复用的价值在移动设备系统上具体化并最大化,从而具备更高的灵活性和兼容性;其背后软件工程的意义在于为软件需求、设计之间架起一座桥梁,解决了系统结构和研发需求向实现平坦过渡的问题。图4. Android系统进程架构概要 图5. 使用设备能力的典型调用路径 当然,历史上其他公司面临这类挑战时也有不一样的想法,例如Windows Phone 8.0选择了另外一条路,无论是提供媲美JAVA的C#及VB.NET框架、还是基于Sliverlight Dependency Property + XAML的UI系统、甚至是为了支持C++研发出来的C++/CX及一套运行时,都仿佛无时无刻标榜着其系统技术的多样化与复杂性,可以算是一场技术盛宴。但这种解决方案更多是从公司自身优势出发,通过不断增强并加大Runtime复杂度来支撑混合技术能达到目的,实际却是过分迷信技术本身以及技术先进性,缺乏对客观情况的深入分析。Meego则是另外一个例子,号称救Nokia于危难,由Intel联袂推出,通过各种开源能力的组合来完成系统的建设,如Linux内核+QEMU模拟器+QT+QML界面,但实际上昙花一现。这样一个系统是否迎合时代并满足发展需要,本质上是否有任何更先进的变化?从当时与Nokia & Intel共同研发的经历来看,其更像是一种仓促应战的产物,难以扭转趋势扶大厦于将倾,自然更无法与打磨多年、处心积虑又步步为营的Android匹敌。 2.3 应用的基础-接口层 系统能力基本就绪,如何迎来更多开发者对Android长远发展至关重要。选择JAVA作为上层语言,既需要勇气又足够彰显其野心;为迎合资源受限这一移动领域过去、现在也是未来的最大客观事实,设计了基于寄存器架构、可执行文件更小的Dalvik虚拟机,并通过净室工程来高质量实现,最终结合诸多工具对外提供了流畅的JAVA编程方式,摆脱类似MTK feature phone只能用KJava写些小游戏的局限,使得Android研发兼具JAVA的便利和不错的性能。 天有不测风云,SUN在09年4月被Oracle收购,距离Android 1.0发布还不到一年。虽然最初选择Apache Harmony来提供JAVA API已是不错选择,但依旧遭遇到技术上如不支持JAVA 7/8特性、版权上如Oracle接踵而至的诉讼等诸多挑战。为应对这一切,Google从Android N开始,将对JAVA的支持变更为OpenJDK。虽然OpenJDK是Oracle开源项目,但用上它恐怕依然如芒在背,加上Android体量已不可同日而语,进一步规避JAVA的风险被提上日程,映入眼帘的Kotlin因为特性相近、又可被编译为class或者dx字节码,获得Google青睐和收编(图6)。 图6. Android接口层的过去和未来 实际上,之所以Android敢这么做,也还是因为有其设计基础的支撑,根据个人的一点粗鄙了解,从Android API的调用链路(图7)上能发现一点端倪:无论底层依赖、实现和流程如何变化,上层的使用形式并不会改变。图7. Android内部对调用链路的3种实现这意味着几乎所有系统能力的核心,已在native library被实现殆尽,并结合上层提供良好屏蔽,为其他语言实现Framework提供可能,尤其是一门特性与JAVA相近的语言。所以是什么语言、是不是kotlin都只事先设计规范下的一种合适的选择。 尽管未来Kotlin和JAVA将依旧将琴瑟和谐,但考虑到Google不遗余力地追捧Kotlin以及自身的良好设计,这为将来Android与JAVA版权问题的彻底了断埋下一种极端可能(图8)。图8. 一种未来用kotlin代替java的极端可能3. 对于我们的象征意义和实践 综上所述,Android从三个方面来解决其发展的关键问题: 硬件驱动:形成厂商的合作基础,并反过来对整个产业施加影响 组件化:高效组织各种内部能力,寻求自身的更快发展接口层:满足上层对系统和硬件能力的各种使用诉求 移动互联网产业巨头发展因为起点以及执行理念不同而有所不同,Apple围绕着其AppStore构建其整个体系并精心维护,而且在现代化API编程、整机体验、垂直领域技术如网络/算法等各纵深领域走在前列;Google则用Android带路,需要在各个层面维护和团结不同力量走出一条自己的路。所以,Android本身为系统如何发展提供了另外一种解答:除关注系统自身能力的发展,如何维护好系统不断发展的基础和前提、如何更好地暴露和让外界使用系统能力也至关重要(见图九)。 图9. Android设计对解决问题的启示 回到我们自身,在重用户、重交互、手机即人的今天,终端中间件有理由也有必要用自身丰富的内涵延展和放大服务的价值。要做到这一点并非易事。首先,业务迭代越来越快,各种应用层出不穷对中间件意味着广泛的需求;其次,环境在改变,无论是运行硬件和设备的五花八门,还是对接服务以及集群的丰富多样,都对集团原有端侧中间件带来巨大冲击;再次,在基础技术发展变缓的今天,技术的价值需要被持续放大,我们希望基于自身能力来构建服务和业务的泛连接基础,并将其作为发展愿景。于是这要求我们基于集团背景以及核心APP发展的主要目标下,来统性思考这个发展问题(图10)。 图10. 对泛连接能力建设的思考 通过Android的启发,结合环境和现状,在满足业务目标的同时我们从三个层面不断演进网络能力(图11): 首先,通过覆盖线上线下、各类场景、形态各异的设备,并不断打造高效私有、支持通用标准的协议,来构建设备和服务、用户与业务的泛连接基础。 其次,我们自底向上地抽象,将非阻塞IO复用、用户态网络栈支持、通道能力扩展以及可支持混合集群的多实例架构进行高效组织,从而保障了数据在不通层面的流转和管理诉求。 最后,基于SDK矩阵和接入能力的建设,我们实现了服务接入到业务、业务透出给用户这一双赢局面,并提供丰富的体验、性能甚至是业务和场景数据,为集团发展带来更多价值。 图11. 泛连接能力的系统性建设 通过多年以上工作的不断迭代,目前我们已能触达海量设备和用户,成为接入集团内外各服务和平台的接口,并为终端和服务分别屏蔽集群的多元化及设备的多样性,实现新零售系统能力与用户的泛连接(图12)。 图12.团队能力在集团中所处的位置 4. 小结 结合传统的C/S观念,服务端获取的信息来源于各网络终端,网络+协议屏蔽或规范了外界对服务输入的多样性,使得服务端过去关注的是集群和高并发,但现在无论是上云还是利用率,背后都是业务、成本规模和边际效应在驱动,这里面发展的代际主旨鲜明。但回到客户端,由于受到环境和交互等多样性直接影响,即便是动态性的技术也难以代表端侧的全部甚至是主流。所以在某种局部技术比拼武功,成为过去客户端的一种行业“潮流”。 在局部技术和单点深入的确有其意义,笔者也曾有过一些班门弄斧,如非轮询方式获取手机栈顶Activity、面向阿里特有复杂集群的SDK多实例设计、Sophix热修复云上产品等。但结合过往经验及Android设计,立足于如何最大化技术的业务甚至是商业价值,可以更系统性地看待技术尤其是客户端技术的发展:即除了满足业务核心诉求外(因为投入大量资源,必须、肯定要成,至少小成),更应该关注技术如何更好地服务业务以及如何持续挖掘能力护城河这两头的问题。所以要打造好一个系统,除构建各中坚能力外,还需维护好系统发展的前提、组织好各系统能力的内聚、满足好外部对系统的诉求。 以上是个人从Android系统设计到技术支撑系统发展的一点浅薄看法。
最近在做些百川、ACCS以及AUS 等 SDK 产品化的事情,很容易遇到主程序在集成时会出现方法数超标的问题,即时分 dex 主 dex 的方法数也经常不够用。虽然每次问题都有同学解决, 但回想起这几年 Android 程序员和方法数之间林林总总的相爱相杀,发现很多问题只能事前疏通而缺乏事后防范、总结,所以还是觉得有必要小聊下方法数这个话题。 一、引子 方法,对于开发者来说是程序中一段代码的定义,而对于执行方(OS、虚拟机、解释器等)来说,就是一个存储在可执行对象(C 的 elf、Windows 的 pe、Java 的 jar等)中的符号或指令。方法数也不是什么新奇玩意,java 的 class 文件中就有定义,elf 的符号表也有隐含体现,类似的还有变量数等定义。在 Android 平台大行其道之前,对方法数讨论的问题不多。直到 Facebook 2013 年的一篇文章[1],提到一些大型应用会遇到的两个方法数问题: dex 方法数超标 linearAlloc 存储方法数的空间在 Android 2.3 及以下只有 5 MB 当时国内少数巨无霸应用在遇到这类问题后,也根据 Facebook 这篇文章的思想实现了分 dex 的方案(如下图的代码片段);甚至完成对 linearAlloc 的修改,但 Android 2.3 及以下的机器份额日益减少,这个兼容已不再重要。 随着非 BAT 企业对繁荣和需求的进一步诉求,遇到 Android 方法数问题的产品也日益增多。对dex格式进行分析,会发现 dex 本身并没有对方法数进行限制。dex 方法数受约束的真正原因是 dex 字节码的设计: “The storage unit in the instruction stream is a 16-bit unsigned quantity” 由于字节码在调用方法时,必须显示寻址方法在 dex 存储的索引,即meth@BBBB[2]。BBBB 的含义是每个四位,四个 B 就是十六位,所以最多支持 2^16 个方法。为保护 dex 字节码的执行,所以在生成、合并 dex 时会对方法数、变量等进行检查和保护。Google 在 5.0 已推出分 dex 的 workaround: multdex,虽然不够完美,但已经使得这类问题的解决开始趋向集中。 二、 正文 实际上,控制方法数问题的根本要义就是减少打入到 dex 中的方法。Dex 是 dalvik 虚拟机的字节码文件,class 是 java 虚拟机的字节码,虽然两者在格式、语法和实现上有一些差别,但本质还是存在一一映射的关系,如下图: 与 class 格式类似,dex 用一段连续的空间存放方法的索引集,每个方法被一个 method_id_item 数据结构所描述,由 class_idx, proto_idx, name_idx 这三个元素组成[3,4], 它们分别代表方法所在类类型索引、方法声明的索引以及方法名的索引。 如下图所示,Dex 中所有方法都来自 Android 的 java 代码(不排除以后有其他语言可以被编译为 dex 格式),而 dex 是由 Android 打包时会通过 dx 工具将编译为 class 的 java 文件转化而来。 可以发现 java 文件的来源如下: 引入的 aidl 文件 参与编译的 java 源码 根据资源生成的 R 文件 依赖的其他库(会被一同打入到编译结果的) 事实 99% 的方法都来自开发者创建的 java 文件或者引入的库中,那么 java 文件会从哪些方面对方法数产生影响? 1. 调用的真相 定义方法的根本目的就是要调用它。为了说明调用方法的意义,下图给出一个简单的示例:声明两个类 MainActivity 和 Test,这两个类都有一个 foo 函数,里面执行了 Activity 的 startActivity。 反编译生成的 APK,得到 dex 对应的 smali 文件(smali 是 dex 的汇编器,和 dalvik 一样都是冰岛语,是一脉相承的东西)。可以看到调用 Activity 的 startActivity 的字节码出现在 Test 和 MainActivity 中。 那么这种方法的调用会不会增加 dex 的方法?先记录下当前的方法数为24个。 继续验证,这次只改动一个地方:将 Test 类中 foo 函数的参数类型改为 MainActivity。依旧是调用库方法,不同的是调用者的类型由父类 Activity 变成 子类 MainActivity。 经过反编译分析,发现smali 红框中的方法其所在的类也相应地变为 MainActivity,再计算方法数变为 25,__增加 1 个__。所以即便是调用方法,也会增加方法数。 导致方法增加的事实是:当类 A 的实例 a 调用了被 invoke-virtual 所修饰的方法 f。在编译期,A 的 字节码中会增加方法 f(如果 f 不在 A 中),即便 f 没被 A 复写或者 f 在 A 的父类中被标记为 final,也阻止不了编译器这样的行为,这是由于虚拟机要实现多态特性而决定的。在运行期,当虚拟机执行到 A 的实例 a 调用 f,如找不到 f 则会出现 NoSuchMethodException。 因为多态和复写是 OO 最常见的编程手段,假如滥用继承且祖先类中的方法很多,那么所有祖先类定义过的方法都会添加到子类中,从而导致方法数膨胀。所以除了进行字面意义上地减少方法,还可以从设计角度来解决这类问题。 综上所述,决定一个方法的三个要素是方法参数列表和返回值、方法名称以及该方法所在的类,修改任何三要素之一都会导致方法数的增加。换一个角度思考,其实不同 class 文件中的相同方法符号会在生成 dex 时被合并,这也是我认为 dex 和 class 两者设计理念的最大区别:dex 格式提供聚合能力。至于用栈还是寄存器来实现相比顶层设计的意义便没有那么显著。其实这个优化思路更早的痕迹出现在 C 语言的链接器中,如下图所示,链接器通过合并 目标文件相似段(elf 格式)来获取更好的性能和扩展性,这个过程和 dx 将 一系列 class 转化为 dex 如出一辙。 2. 甜蜜的负担 纵观世界编程语言发展史,java 经常被拿来与 C# 对比,但两者的发展理念早已大相径庭。例如 C# 吸取了很多语言的特点,也更像一个大杂烩,很早就提供了 lambda 表达式、 async 关键字以及丰富的异步 api 接口,看过去的确琳琅满目、功能强大且能帮助快速开发,但实质上如果不清楚其内部原理和实现机制,很容易使用不当且造成隐晦甚至是灾难性的后果。java 在这方面并没有亦步亦趋,更像是一个按着既有计划前进的长者。 为了让使用者更为得心应手,java 每个版本也持续都引入了不少新特性,例如 1.1 的内部类、1.5 的泛型、1.8的 lambda 等,满足了开发者不同的诉求。 这里我们来看看语法糖对方法数的影响,下面两个文件分别在类 Test 中定义了 foo 和 toArray 两个方法,类 Test2 继承 Test,并重写了 foo 的返回值。 我们发现 foo 返回值的类型被改写,基类 Test 中的方法 foo 返回的是 Object,而子类 Test2 返回的是 Object 的子类 Long,这种用法的好处在于为多态提供了更多的扩展性,能够让子类的实现更为聚焦,平时一些常见的程序库中就采用了类似用法。分析和对比字节码发现:子类 Test2 中会存在两个 foo 方法,原因是编译器会在子类 Test2 中生成(synthetic)一个与父类一致的方法来做被复写类型的方法的桥接(bridge),从而实现这一便捷的语法。继续查看泛型方法 toArray 的字节码,其底层是使用 Signature 字节码关键字来标记被擦除前的类型信息,而泛型本身并没有引入额外的方法。 除此之外,java 中最常见的语法就是使用大量的内部类、匿名类,这一块比 C++ 方便不少。在类 Test 中我们使用匿名类和内部类来观察他们对方法数的影响。 类 Test 中的内部类和外部类会相互访问一些具有 private 权限的方法和变量: 继承 Runnable 的匿名类 Test$1 会访问到外部类 Test 的私有变量, 外部类 Test 访问 静态内部类 Test$CS 和内部类 Test&C1 定义的私有方法 对于匿名类访问外部私有变量的情况,可以发现 Test$1 会通过 Test 的 access$000 静态方法来获取其私有变量的值,access$000 是编译期在 Test 中生成。 对于外部类访问内部类私有方法的情况,也会生成相应的静态方法 access$xxx 来帮助突破限制。 值得注意的是 Test 访问内部类的 private 变量却没有增加方法。这是因为由于 Test$CS 和 Test$C1 是常量,编译期就已经确定 c1_.I + CS.I 的值。 同理,如果这些变量不被 final 修饰为常量,那么编译器也会为它们生成 access$xxx 方法来突破访问限制。 综上所述,语法糖的本质是带给开发者以更为便利的使用,这种便捷如果建立在与语言原有设定不一之处,要不就是缺陷,要不就是编译器在背后做了不少。无论是复写返回值还是突破访问权限或者一些类似 lambda 等新语法,它们无一例外地以增加方法、内部类等字节码为代价来实现这种便捷,通过这种手段来屏蔽掉一些不重要的细节,将最令人关心的特性呈现给开发者。 3. 结构的背后 如果要书写一个 java 文件,难免要在 abstract class、annotation、class、enum、interface 这五种结构中选取或者组合,它们又在方法数上又有何差异?我们定义这五种结构最简实现,即没有任何方法和成员(用 T_XX.java 命名,XX 表示这些结构前两个字的缩写),来看看不同结构对方法数的影响。 通过反编译 smali 文件分析可得: 这里篇幅问题就不列出字节码了,综上所述: 接口和注解没有引入方法,字节码的大小也是最少 类和抽象类引入了一个方法(会调用 Object 的默认构造函数),大小理论上相同。(除去类名长度等因素,上图 T_AB 与 T_CL 的字节码大小相差 9 个字节是因为抽象类的描述比类的多了 abstract 关键字 加 1 个空格所致) 枚举引入了 1 个默认构造函数和 3 个静态方法,所需的字节码最多,是其他结构的数倍甚至二十倍。枚举有其特性和优雅之处,但使用过多也会对方法数和程序大小产生影响。 三、 小结 本文简单介绍了方法数的来龙去脉,优化方法数的文章也很多。但最有效的还是要在设计初期就把这个问题考虑进去,这里只聊点 the principle underlying。具体的优化和设计方案如对引入依赖的处理、怎样避免方法数膨胀等还需围绕原则,结合项目实际特点来选取和开展。 四、 资料 [1] https://www.facebook.com/notes/facebook-engineering/under-the-hood-dalvik-patch-for-facebook-for-android/10151345597798920[2] http://stackoverflow.com/questions/21490382/does-the-android-art-runtime-have-the-same-method-limit-limitations-as-dalvik/21492160#21492160[3] http://pallergabor.uw.hu/androidblog/dalvik_opcodes.html[4] http://www.netmite.com/android/mydroid/dalvik/docs/dex-format.html