头图

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

阿里巴巴移动技术
English

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

背景

近年来,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)
    end
end

CocoaPod和Xcode编译卡口

  • 主工程CocoaPods环境升级到1.9.1版本,update时会检测循环依赖;
  • 去掉兼容的Header search Path逻辑,模块必须使用规范的头文件引用方式才能编译通过;
  • 开启XCode modular编译检查,如果模块的头文件引用不规范会编译不过。

Devops构建卡口

  • 严格走集成单流程,集成单需要编译通过才能集成;
  • 在构建流程中加入静态扫描插件,检测模块规范。

总结

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

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

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

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

参考

我们招聘啦!

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

简历投至方式

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

微信号:blackteachinese

淘宝客户端诊断体系升级实战

Cube 技术解读 | 支付宝新一代动态化技术架构与选型综述

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

阅读 725

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

29 声望
992 粉丝
0 条评论
你知道吗?

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

29 声望
992 粉丝
文章目录
宣传栏