写点什么

Alibaba iOS 工程架构腐化治理实践

发布于: 4 小时前
Alibaba iOS 工程架构腐化治理实践


“ 业务开发遇到环境问题越来越多,严重影响开发效率,有些表面看似打包问题,背后却是工程架构的腐化。”

背景

近年来,iOS 工程复杂度高的负面影响逐渐暴露,很多同学都受到了 iOS 打包慢和打包复杂的“摧残”,业务开发效率受到很大影响。我记得曾经有个同学跟我诉苦,他把几个模块打包后集成到主工程,这个过程中每个步骤都有打包失败,总共花了大半天时间。


Alibaba.com 是跨境 B 类电商业务,2012 年开始开发 iOS 客户端。为了支撑业务发展,2016 年进行组件化改造,从单一工程架构演化模块化架构。随着业务和无线技术的发展,客户端已经从小型模块化工程演化为一个巨无霸工程。团队一共建设了 100 多个自维护模块,包括业务模块、架构设施、Hybrid 容器、Flutter 容器、动态化技术、基础中间件等能力。表面上工程架构正在有序地演进,但内部已经乱象丛生。模块关系混乱,循环依赖和反向依赖行为越来越多。大量模块不符合 LLVM Module 标准,spec 文件不全、头文件引用不规范。因为工程不规范,Cocoapods 无法升级,只能使用 1.2 和 1.5 旧版本,技术上落后了 3 年以上。


为了彻底解决问题,提高业务开发体验,阿里巴巴 ICBU 端架构组对 iOS 工程架构进行全面地治理。我也写下一篇文章记录自己的思考,欢迎有兴趣的同学指导交流。


Steve Mcconnell 《Code Complete》:“软件的首要技术使命:管理复杂度。”

架构腐化会产生哪些问题?

问题一:模块打包复杂度高

工程环境混杂

2016 年 Alibaba 客户端组件化做的并不彻底,很多模块只是形式上的分离,实际上还存在反向依赖和循环依赖问题。到了 2017 年,团队想做 Framework 化,发现模块单独打包编译不过。于是,为了模块编译通过,我们开发兼容脚本,将所有 framwwork 和头文件都添加到工程 searchPath 里,并且让模块直接读取同步主工程 Profile 里所有依赖。自从有了兼容逻辑,spec 文件不写依赖描述也能编译得过,于是再也没人维护 spec 文件,跨模块的头文件引用也越写越乱。

环境不兼容 &模块构建失败

因为存在循环依赖、头文件不规范等问题,模块编译脚本加了许多 workaround 逻辑,兼容头文件索引。这导致模块 Cocoapods 环境无法升级,一直停留在 1.2 版本。而随着中间件和社区 swift 技术越来越多,主工程 Podfile 用了 cocoapod 1.5 的新语法。环境开始不兼容。同时,模块解析主工程 Podfile 时,无法识别 cocoapod 1.5 的新语法,模块构建失败。

每年浪费了 90 人日的开发资源

模块打包失败后,开发需要分析日志,排查打包失败原因,若分析不出来则需要找架构组支持。一个模块打包失败,会一直卡住需求不能集成,会阻塞测试或其他开发工作。


根据开发反馈的情况,估计平均一次模块打包失败要消耗 2 个小时的研发资源。据统计,Q1 期间,模块打包失败总数高达 200 多次,其中 70%的打包失败是因为复杂度过高导致的。每一次打包失败浪费 2 个小时,相当于每年浪费了 90 人日的研发资源。


Robert Martin 《Clean Architecture》:“不管你们多敬业,加多少班,在面对烂系统时,你仍然会寸步难行,因为你的大部分精力不是在应对开发需求,而是在应对混乱。”

问题二:主工程打包慢

如果模块不规范,又需要引用 swift 中间件,无法独立静态库,只能以源码形式集成到主工程。这导致主工程打包时需要编译大量源码,平均打包时间比手淘、优酷等工程慢 12 分钟。需求提测、集成、修复 bug、排查问题时都需要进行主工程打包,打包慢会阻塞开发和测试的工作。某一次双周迭代打包了 70 次,浪费了 14 个小时。

问题三:工程环境不稳定

Cocoapods 环境不能升级,只能使用 1.2 和 1.5 的旧版本。但旧版本环境没人维护,环境极其脆弱,比如有人发布了一个不合法的 spec,Pod Update 就会挂掉。因为模块不规范,源码开发时会出现各种莫名其妙的编译问题。业务开发和调试效率会很低,浪费大量的时间。

问题四:Swift 开发寸步难行

近几年 swift 普及,iOS 社区和集团 swift 中间件越来越多。然而,Swift 模块严格遵守“LLVM Modules”规范,不允许循环依赖、外部依赖要显示声明、头文件引用要采用尖括号,否则就会出现“could not build module xxx”、“No such module”等错误。高标准的要求下,我们的工程开发引入 Swift 寸步难行。虽然我们自己可以不使用 Swift,但集团和三方的中间件 Swift 化的趋势是不可逆的。


近两年,Alibaba.com 的工程引入许多 Swift 中间件,同时也自主研发了许多 swift 组件,这也彻底引爆了研发效率的问题。相关模块频繁打包异常。不规范问题错综复杂,经常解决完一个编译器错误,又出现另一个错误,子子孙孙无法穷尽。最后系统开始出现各种不可控风险。


此外我们大部分模块都不符合 LLVM Modules 规范。如果业务需求使用 Swift 或引用到 Swift 中间件,就要花大量时间去解决适配问题。根据敏捷迭代的数据,需求 A 计划 10 人日,实际消耗 20 人日,需求 B 计划 6 人日,实际消耗 10 人日。


复杂度的恶化到一定程度,一定进入有诸多 unknown unknown 的程度

问题五:历史代码清理困难

最近几年很多旧业务已经下线或改造。但因为模块之间耦合严重,许多旧代码一直不敢删,这也导致包大小持续膨胀。

架构腐化治理的困难与策略

影响范围广,治理难推动

2020 年,我在 iOS 技术栈发起了架构治理项目,发动各个业务线的 iOS 开发一起治理,却陷入了困局。一方面,业务开发没有投入资源。另一方面,许多业务模块之间调用关系混乱,治理风险高,大家都不敢随便动。

数据化分析,自顶向下推动

iOS 工程的混乱已经严重影响了业务发展,大家时间都浪费在解决编译打包问题上。各业务的 iOS 开发同学都被困扰,许多开始反馈因为打包困难严重影响开发效率。


为此,我开始全面梳理研发流程的数据。一方面,我统计了模块构建失败数据,主工程打包的耗时,然后再结合其他客户端的数据进行对比;另一方面,我对业务开发做访谈,从用户的角度了解资源浪费的数据,补充研发平台中无法统计到的环节。最后,成功将工程混乱对研发效率的负面影响量化为具体的数据。


有了数据分析结果,就有了推动的抓手,可以自顶向下推进架构治理。

解决方案

纵观全局,理清模块依赖关系

第一个难点是模块的关系不清晰。模块描述文件里依赖列表都是空的,模块之间的关系就像一团毛线。


模块的关系不清晰,治理项目就无法拆解,成本也估算不出来。因此要先纵观全局,分析整体的模块依赖关系。


我开发了一个工具进行分析。首现查找模块的所有文件,使用正则匹配找到它 import 的外部头文件,得到外部引用的头文件集合。然后搜索主工程的 Pods 目录,匹配头文件所属的外部模块,最后聚合得到完整的模块依赖树。


下一步是视觉化,视觉化以后可以更直观地查看模块关系的复杂度,方便制定治理计划。我使用了 Dot language 来描述模块关系,可以自动生成整个工程的依赖关系图,也可以生成某个特定模块的依赖关系图。



依赖倒置、分层治理

第二个难点是治理的依赖条件复杂。


模块治理成功的标准是整个依赖树的所有模块都没有循环依赖,并且都符合 LLVM Module 规范。比如治理业务模块 A,模块 A 的依赖树里有一个模块 C,模块 C 存在循环依赖或不符合 Module 规范,A 模块打包时就会报异常.而 Cocoapod 和 XCode 每次只报一个异常,不能分析整个依赖树所有的问题。


我们工程自己维护的模块有 130 多个,三方库和中间件模块 200 多个。业务模块除了自身依赖,还有许多间接依赖,依赖树非常复杂。这种情况下,直接治理业务模块复杂度极高,治理过程也会很混乱。


上图的示例中,模块 C、模块 I、模块 G 是关系复杂的中心模块。比如“模块 I”直接依赖了 30 个外部模块,间接依赖 100 多个模块,它直接耦合关系有 5 个循环,间接耦合关系 15+个循环。如果直接治理“模块 I”,需要解耦 15 个循环关系,将 100 多个模块进行 Module 化改造。按照这样的思路治理,修改逻辑极其复杂,很可能治理到一半就进行不下去。


为了解决这个困局,我对模块进行分层和分类。划分的基础逻辑有 3 个:


  1. 越是底层的模块依赖关系越简单;

  2. 没有循环依赖的模块更容易治理;

  3. 治理完成的模块可以被忽略。


按照这个思路,我先梳理清楚模块所属的层次,然后自底层逐层向上治理。当底层模块都治理完,依赖多的模块负担也会大大降低。当底层的循环依赖解耦完成,上层的模块就不用处理的间接循环依赖。


最后使用四象限分析法,将模块分为 4 个组,1 基础模块无循环依赖、2 基础模块有循环依赖、3 业务模块无循环依赖、4 业务模块有循环依赖,按顺序治理每一组。



自动化修复

第三个难点是代码改动量大。模块治理面临许多子问题,“模块 spec 文件的依赖描述不全”、“umbralla 头文件不缺失”、“public 头文件引用不规范”、“循环依赖解耦”。仅仅修复“模块 spec 文件的依赖描述不全”就很困难。


补全依赖的方法是查找所有源文件的 import 描述“(import <xxxFramework/xxx.h)”,统计以来的所有 framework。再基于 framework 名称反向查找所属的模块。另外有很多 import 格式不规范,有些是直接引用文件名(import “xxx.h”),有些是路径方式引用(import <xxx/xxx/xxx.h>),遇到这种不规范的引用,还需要全局搜索才能找到属于哪个模块。举个例子,模块 A 的 dependence 描述是空的,但实际上它依赖了 20 几个模块。模块 A 有 60 多个源文件,每个源文件 import 引用平均是 10 行,总共 600 行引用代码。如果人工分析这 600 行代码,估计得花一天时间。这还只是修改其中一个问题,还不包括“umbralla 头文件不缺失”、“public 头文件引用不规范”、“循环依赖解耦”。


因此,纯人工治理根本行不通,必须通过自动化的方式提高效率。于是我开发了一个架构管理引擎,可以用来分析模块依赖关系,也可以修复 spec 依赖描述不全、自动生成 umbralla 头文件、修改不规范头文件引用等等。自动化的修复工具可以覆盖 95%的代码改动量,开发只负责修改路由、服务 API、代码迁移、模块拆分合并等变化较大的逻辑改动。


架构管理引擎不仅可以做架构治理,它还能做为团队管理工具,比如分析 git 仓库活跃度,批量设置 CodeReview 规则,记录研发过程的日志。



下面这段代码使用了 ruby 语言和 cocoapods-core 框架,主要功能是分析模块 import 代码,修复模块的 podspec 的依赖。


require 'cocoapods'require 'cocoapods-core'require 'xcodeproj'def DependencesAnalyser.main(contextHelper, projectToolPath, moduleName, allModuleNames)    # 1修复import格式    iOSProjectDir = contextHelper.projectDir    podDir = contextHelper.podDir    iOSProjectName = contextHelper.projectName    # 读取source_files路径    sourceDir = contextHelper.sourceDir    if sourceDir.nil?      puts '[error]依赖修复失败,找不到正确的sourceDir'      return nil    end    # 1 读取源文件目录下的所有.h和.m文件的路径    allheadPaths = getSourceHeaderPath(sourceDir)    # 2 遍历所有源文件,读取文件的每一行,正则匹配出所有import的代码行    # 2.2 如果是import "" 或者 import <xx.h> 规则引用的,解析出依赖的头文件    importHeaders = parseHeaderNameFromQuotationImport(allheadPaths)    # 2.1 如果是import <xx/xx.h> 规则引用的直接截断出framework名    dependences = parseFrameworkNameFromAngleBracketsImport(allheadPaths)    # 3 如果是import "" 规则引用的,判断引用的头文件是否存在Pod目录下,如果存在记录所在Pod的Framework名    # 3.1 读取主工程Pod文件目录下所有依赖库的.h文件的路径    dependencesFromQuatationImport = findFrameNameFromQuatationImportHeader(podDir, importHeaders)    dependences = dependences + dependencesFromQuatationImport    filtedDependences = filterDepencences(dependences, projectToolPath, moduleName, allModuleNames)    # 4 读取podspec,修改dependence后,输出新的podspec文件    modify_spec_file(filtedDependences, contextHelper)    # 5 输出依赖关系文件    return filtedDependences  end
复制代码

架构和业务合作治理

第四个难点是解耦涉及大量业务逻辑。很多代码是业务的分支逻辑,重构后很难测试,如果不全面验证很容易出线上故障。


解耦涉及大量业务逻辑,降低风险最好的方法是交给业务开发来修改。因此架构组牵头了横向的 iOS 工程治理项目,架构组提供治理方案和工具,业务开发负责业务逻辑解耦。业务解耦采用了 4 种方式,路由 Scheme、服务化 API、公共组件下沉、模块合并。


举几个典型的解耦场景:


场景一, 产品模块里有一个子业务是产品推荐,订单模块也需要用到,于是订单模块会反向依赖产品模块,形成循环关系。这种场景解耦的方式是从产品模块中拆分出基础组件,订单模块依赖基础组件。



场景二, 产品模块跳转订单模块时使用产品的 model 作为 API 的入参,订单模块为了引用产品的 model,反向依赖了产品模块。这种场景解耦的方式是使用路由 URL Scheme 协议,将 model 转化为 URL 中 query 的入参。



长效保障机制

进行架构治理后,模块的循环依赖和 modula 规范等问题得到解决,但今后可能出现二次腐化。我们当然不希望隔一段时间又要重新治理,于是从架构设计和研发流程的卡口入手,优化架构和流程,杜绝后续的二次腐化。

架构优化

  • 系统性进行模块定义和划分,增加模块逻辑的内聚性,避免一个需求需要同时开发多个模块。收敛模块数量,减少模块的维护成本;

  • ICBU 业务模块最终都会集成到主客,版本仲裁统一在主工程可以减少复杂度,避免模块的版本声明出现冲突。模块依赖描述只声明模块名,不声明版本号,打包时同步主工程的模块版本作为版本仲裁。

收敛模块工程

如果模块各自维护构建工程,长期维护必然导致构建配置有很大差异。一方面,这样不能统一升级构建配置,架构治理和技术升级的成本会很高;另一方面,模块如果出现构建问题,排查成本也会变高。


因此,我们建设了打包脚本,每次打包动态生成模块工程。模块不再维护独立工程,构建配置统一收敛到 podspec 文件。


模块打包时,动态创建模块的构建工程


require 'cocoapods'require 'cocoapods-core'require 'xcodeproj'require 'rubygems'
project_creater = ProjectCreater.new(ContextHelper.tempProjectPath, ContextHelper.projectName)project_creater.transform
require 'pathname'
class ProjectCreater def initialize(root, name) @project_path = Pathname.new(root).realpath @project_name = name end
def transform puts "ProjectCreater-开始" prepare puts "ProjectCreater-开始重命名" rename puts "ProjectCreater-完成" end
private def prepare xcodeproj_path = @project_path.join("#{@project_name}.xcodeproj").to_s if File.exist?(xcodeproj_path) `rm -rf #{xcodeproj_path}` end end
def rename Dir.glob(File.join(@project_path.join("Podfile").to_s)).each do |file| content = File.read file content = content.gsub(/POD_NAME/, @project_name) File.open(file, 'w') { |f| f << content } end
Dir.glob(@project_path.join('PROJECT.xcodeproj').to_s + '/**/*').each do |name| next if Dir.exist? name if File.extname(name) == '.xcuserstate' next end text = File.read name text = text.gsub("PROJECT",@project_name) File.open(name, "w") { |file| file.puts text } end
scheme_path = @project_path.join("PROJECT.xcodeproj/xcshareddata/xcschemes/").to_s File.rename(scheme_path + "PROJECT.xcscheme", scheme_path + @project_name + ".xcscheme") File.rename(@project_path.join("PROJECT.xcodeproj").to_s, @project_path.join(@project_name + ".xcodeproj").to_s) endend
复制代码

CocoaPod 和 Xcode 编译卡口

  • 主工程 CocoaPods 环境升级到 1.9.1 版本,update 时会检测循环依赖;

  • 去掉兼容的 Header search Path 逻辑,模块必须使用规范的头文件引用方式才能编译通过;

  • 开启 XCode modular 编译检查,如果模块的头文件引用不规范会编译不过。

Devops 构建卡口

  • 严格走集成单流程,集成单需要编译通过才能集成;

  • 在构建流程中加入静态扫描插件,检测模块规范。

总结

架构腐化就像“流感病毒”,它的负面影响很难被感知和量化。


对于技术团队而言,要避免架构腐化,技术团队要对技术有更高的敬畏,相比于等大火蔓延再就抢救,我们应该对及时灭火的人给与更多实质性的支持和鼓励。


对于架构师而言,需要架构师能熟练开发工具。面对复杂的度架构问题,首现要进行全面分析,对系统问题进行拆解,找到复杂度最低的治理路径,并有意识地寻找数据支撑,获得团队的支持。


最后,从架构治理的角度。客户端工程是天然中心化架构,它很容易因为环境冲突导致编译问题。因此,我们设计组件化架构时,要确保模块的环境完全独立,避免出现中心化架构。架构治理不是终点,治理完成后要有防止腐化的机制,避免出现二次腐化。


参考

  • 《Clean Architecture》https://book.douban.com/subject/26915970/

  • 《Code Complete》https://book.douban.com/subject/1432042/

  • DOT Language https://graphviz.org/doc/info/lang.html

  • LLVM Module https://clang.llvm.org/docs/Modules.html#introduction

我们招聘啦!

Alibaba.com 是全球最大的 B 类国际化电商平台,长期招牌端架构、直播、短视频、IM、电商等领域的技术人才。如果你对 iOS、Android、Flutter 等移动技术充满热情,欢迎加入 Alibaba.com 客户端研发团队,可以 Base 杭州和深圳。


简历投至方式:

联系邮箱:blacktea.hw@alibaba-inc.com

微信号:blackteachinese




关注我们,每周 3 篇移动干货 &实践给你思考!

发布于: 4 小时前阅读数: 10
用户头像

还未添加个人签名 2018.07.07 加入

阿里巴巴移动&终端技术官方账号。

评论

发布
暂无评论
Alibaba iOS 工程架构腐化治理实践