尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

iOS组件化开发实践:从耦合到解耦的完整改造指南

iOS组件化开发实践:从耦合到解耦的完整改造指南 最近一直在折腾iOS组件化开发起因其实是工程已经膨胀到让人没法忽视的地步一个App里堆了上千个源文件业务模块之间互相import改一行公共代码心里都要咯噔一下git pull之后的编译时间更是“轻则五分钟重则去倒咖啡”。组件化开发这个概念网上讲得很多但真正落地的过程有不少细节是资料里不会写清楚的。这篇文章不是概念科普而是把我这次组件化改造从规划、拆分、落地到踩坑的完整过程记录下来。适合那些团队规模在5人以上、业务模块越来越多、已经明显感觉到工程耦合或编译变慢的iOS开发者也可以当一份可执行的改造自查清单来用。1. 为什么做iOS组件化我遇到的三类痛点1.1 代码耦合到“不敢改”最直观的痛点就是模块之间网状依赖。登录模块依赖用户模块用户模块又依赖订单模块的某个数据结构订单模块反过来还要用登录模块的工具方法。这种依赖在项目早期根本感觉不到因为人少、功能少但到了几十个页面、十几个业务线同时迭代的时候完全就是灾难。我印象很深的一次想改一个底层网络库的超时时间结果发现十几个模块都直接引用了网络层的内部类每个模块里还有自己的一套处理逻辑。改完之后整整花了两天时间验证各种边角场景。组件化之后模块与模块之间只能通过对外接口通信内部实现完全封闭这种“牵连式改代码”的情况会大大减少。1.2 编译时间慢到影响开发效率第二个痛点是编译时间。工程里第三方库多、业务代码多、还有一堆没清理的资源文件我用MacBook Pro跑一次全量编译经常要十几分钟增量编译有时候也要两三分钟。一个业务分支切过来光是等编译就浪费大量时间更别提同时还要处理冲突。组件化本身不能直接让代码编译变快但它为二进制化铺了路。业务组件的源码可以打成静态库或者动态framework壳工程直接使用二进制产物避开了每次改动主工程都要重新编译所有源码的问题。后面我会专门讲这部分的实践细节。1.3 团队协作的冲突与交付节奏第三个痛点是git冲突。两个人同时改同一个文件几乎是常态尤其是工程入口、公共配置、资源文件这种“路过必改”的内容。每次合并分支都要小心处理冲突一不留神就把别人的改动覆盖掉。组件化之后每个组件是独立的git仓库团队按组件隔离大部分改动不再需要在同一个目录下交锋合并冲突自然减少。同时每个组件都有自己的版本号业务模块可以按自己的节奏发布壳工程只要确定依赖哪个版本即可发布流程比以前清晰很多。1.4 不是所有项目都适合组件化组件化不是银弹我也不建议所有项目都一上来就拆。只有两三个开发、产品还处于快速试错期、App功能非常简单的团队现阶段硬上组件化只会增加开发成本。原因很简单组件化意味着要维护私有Pod仓库、版本发布、组件间的通信中间层这些对单人/小团队来说都是额外负担。我认为比较合适的切入时机是业务模块开始出现明显依赖或者团队成员超过5人或者编译时间已经影响到日常开发效率。你在动手之前可以先列一下自己团队的真实痛点再决定要不要上这套体系。2. 组件划分与架构分层先画图再动手2.1 先有一张干净的依赖分层图组件化改造第一步不是写代码而是把现有工程的依赖关系理清楚。我们可以先用文字在白板上画一张分层图从上到下依次是壳工程App Target负责注册所有组件、配置启动流程、处理 app 生命周期。业务组件按业务线拆分比如登录、商品、订单、个人中心。基础业务组件不包含具体页面但带有业务属性比如账号服务、支付引擎。基础技术组件网络库、存储、日志、埋点、路由中间件。第三方基础库AFNetworking、SDWebImage、Masonry这类第三方依赖。关键原则是依赖方向必须从上向下上层可以依赖下层下层绝对不可以反向依赖上层。同一层之间也尽量不要互相依赖如果有公共部分就再沉淀到更下面一层。2.2 业务组件怎么切才合理很多人纠结“组件到底拆多细”。我的判断标准很简单看是否存在一个明确的业务边界和责任人。电商项目就按商品、订单、购物车、用户这种业务线拆工具类App就按功能域拆比如账号、消息、设置。一个业务组件至少要包含页面跳转入口、对外服务接口、数据模型、网络请求这四类内容。切记不要拆成“一个页面一个组件”那是过度设计。粒度太细会导致组件数量爆炸、通信成本剧增。组件化的目的是降低复杂度而不是增加复杂度。2.3 基础组件怎么沉淀基础组件是业务组件能够稳定运行的底座。哪怕业务组件还没开始拆我也会先把基础技术组件整理出来因为它们基本没有业务语义可以独立编译和测试。基础组件一般包括网络层对URLSession或第三方网络库的二次封装存储层Keychain、数据库、UserDefaults的封装工具层日期处理、字符串处理、系统能力封装基础UI颜色、字体、通用UI控件中间件路由、通信、组件注册基础组件的版本迭代要非常克制不要频繁变更API。一旦发布一个稳定版本所有上层组件都依赖它轻易改动会造成连锁反应。2.4 对依赖关系做一次“体检”画完分层图之后接下来要识别出当前代码里的循环依赖。常见的做法是用脚本扫描所有源文件的import或include再生成依赖关系图人工检查“A依赖B、B又依赖A”的环。我当时的做法比较原始但有效把所有.h文件的import按模块归类然后用Graphviz工具画成一张图凡是看到箭头形成环的就标记出来重点处理。处理循环依赖的办法一般有两种把两个模块真正公用的代码下沉到更低层组件或者用协议把依赖方向反转。比如A需要一个网络接口B实现了这个接口那么让A依赖“接口定义”而不是依赖B的具体实现环就解开了。3. 组件化开发的核心工具链CocoaPods、SPM与二进制3.1 CocoaPods是组件化的事实标准说到iOS组件化的基础设施目前最成熟的还是CocoaPods。虽然Swift Package Manager已经越来越完善但在私有库管理、组件版本发布、二进制集成这几个方面CocoaPods仍然有不可替代的优势。用CocoaPods做组件化核心是两套东西私有Spec仓库和组件代码仓库。Spec仓库保存每个组件的podspec描述文件组件仓库保存实际代码和资源。项目里通过Podfile声明依赖pod install的时候会从Spec仓库解析版本从代码仓库下载源码或二进制。创建一个私有Spec仓库很简单一行命令就行pod repo add MySpecs gityour-git-server:ios/MySpecs.git后续组件发布时执行pod repo push MySpecs MyModule.podspecCocoaPods会把描述文件推到Spec仓库其他工程就能通过source字段找到它。3.2 podspec关键字段解读一个标准的podspec长这样我用注释把关键字段讲清楚Pod::Spec.new do |s| s.name OrderModule s.version 1.0.0 s.summary 订单模块 s.homepage https://your-git-server:ios/OrderModule s.author { Team teamexample.com } s.source { :git https://your-git-server:ios/OrderModule.git, :tag s.version.to_s } s.ios.deployment_target 12.0 s.swift_version 5.0 s.source_files OrderModule/Classes/**/*.{h,m,swift} s.resource_bundles { OrderModule [OrderModule/Assets/**/*] } s.dependency NetworkModule s.dependency BaseUI end这里有几个容易踩坑的点s.source里的tag必须和git tag一致否则pod安装时会拉不到对应版本。source_files一定要用Classes/**/*这种递归写法漏了子目录会导致部分文件没编译。资源要用resource_bundles而不是resources因为前者会为每个组件单独生成bundle避免资源名冲突。dependency要写清楚组件依赖谁就是谁不能隐式依赖。3.3 SPM与CocoaPods我站谁Swift Package Manager是苹果官方工具近几年发展很快。对纯Swift代码、公开依赖库来说SPM确实好用但涉及私有库、组件二进制化、非源码集成时SPM的私有源方案还需要搭配Git tag和本地路径依赖体验不如CocoaPods顺滑。我给一个自己的选型判断场景推荐方案团队已经重度使用CocoaPods继续用CocoaPods别折腾新工程、纯Swift、依赖公开库可以SPM需要私有业务组件优先CocoaPods需要二进制化教研优先CocoaPods vendored_frameworks说白了CocoaPods现在依然是iOS企业级组件化最稳的选择。SPM可以作为未来趋势去关注但不要因为它“官方”就盲目切换工具稳定比炫技重要。3.4 二进制化让编译时间从十几分钟降到几分钟组件化之后如果还是全部源码集成每次pod install依然会把所有组件的源码编译一遍编译时间改善有限。所以我会在组件稳定后做二进制化把每个组件的编译产物静态库或framework提交到组件仓库podspec里用vendored_frameworks描述壳工程就不需要再编译组件源码。二进制化的简单思路是在CI持续集成上组件代码合入主干并打好标签后自动执行xcodebuild build生成OrderModule.framework然后上传到组件版本目录或者组件仓库的Binary目录。最后更新podspecs.source_files [] s.vendored_frameworks Binary/OrderModule/1.0.0/OrderModule.framework这时壳工程依赖OrderModule会直接从本地或私有源拉取framework不再编译源码编译速度自然提升。当然二进制化对日常调试不友好。我一般会让Podfile支持一个环境变量比如USE_BINARYUSE_BINARY1 pod install # 用二进制跑Release包 USE_BINARY0 pod install # 用源码方便Debug打断点这样既能享受快速编译又不会把调试体验牺牲掉。4. 组件间通信路由、Target-Action与协议注册4.1 通信问题的本质组件拆完之后原本可以在一个工程里随便import的页面和对象被隔离在不同Pod里了但业务上仍然需要“从用户模块跳转到订单详情”这种操作。组件间通信要解决的就是如何在一个组件不直接依赖另一个组件的情况下调用它的能力。通信方案有很多iOS生态里主流的有三种URL路由、Target-Action、协议注册表。每种都有优缺点我建议按场景混用。4.2 URL路由的落地细节URL路由是最早普及的方案代表组件有MGJRouter、JLRoutes等。核心思想是每个页面定义一个URL组件启动时注册这个URL对应的处理闭包其他组件跳转时直接打开URL。我实践中的代码如下// 订单组件内部注册 [[Router shared] registerURLPattern:app://order/detail handler:^(NSDictionary *params) { NSString *orderId params[orderId]; UINavigationController *nav params[nav]; OrderDetailViewController *vc [[OrderDetailViewController alloc] initWithOrderId:orderId]; [nav pushViewController:vc animated:YES]; }]; // 任何组件内部跳转 [[Router shared] openURL:app://order/detail?orderId123];URL路由的好处是跨模块解耦非常彻底字符串URL可以远程下发适合配合Push/分享场景。但问题也很明显URL里的参数是字符串字典编译期不检查一旦字段拼错只有运行到那一步才能发现。对于业务参数传递我建议在封装层处理成强类型的模型避免裸字典到处传。4.3 协议注册表Protocol-Instances协议注册表是近些年更受推荐的做法。组件A定义一个协议组件B实现并注册这个协议其他组件通过注册表拿到实现实例后调用完全不依赖B。Swift示例// 定义协议一般放在一个中间件或基础组件里 protocol OrderServicing: AnyObject { func orderDetailVC(orderId: String) - UIViewController } // 订单组件内实现 final class OrderService: OrderServicing { func orderDetailVC(orderId: String) - UIViewController { return OrderDetailViewController(orderId: orderId) } } // 组件启动时注册 ServiceManager.register(OrderServicing.self, instance: OrderService()) // 任何地方消费 let service ServiceManager.service(OrderServicing.self) navigationController?.pushViewController(service.orderDetailVC(orderId: 123), animated: true)协议注册表的优点是类型安全编译器能帮我们检查方法签名和参数类型重构的时候更好追踪缺点是需要一个全局的ServiceManager中间件并且要防止注册冲突。这个方案非常适合业务服务调用比如“获取用户信息”“调起支付”。4.4 Target-Action路由Target-Action方案由Casa Taloyum推广核心是用字符串拼接target和action然后通过NSClassFromString和performSelector动态执行。因为完全不需要在编译期依赖目标类所以解耦程度也很高。不过它的动态特性意味着没有任何编译期检查参数只能靠字典传递使用起来很复杂。除非是接手老项目否则我不会在新代码里推广它。作为参考可以了解但不必作为第一选择。4.5 我的混合方案经过这次改造我最终采用的是页面跳转统一走URL路由能力调用走协议注册表Target-Action只用来兼容旧的调用链。原因很简单URL路由适合“页面跳转”这种无状态预期协议注册表适合“获取某个服务实现”这种有返回值的调用。把两者混用之后新代码里没有再出现组件之间直接import的具体类耦合度控制得很好。5. 从单体工程到组件工程的迁移实操5.1 第一步搭建私有Spec仓库与壳工程无论你用GitLab还是其他代码托管先把私有Spec仓库建好。这一步非常关键相当于给所有组件建了一个“货架”。pod repo add MySpecs gityour-git-server:ios/MySpecs.git然后把现有工程复制一份作为壳工程。壳工程只保留App入口、启动逻辑、第三方依赖和组件依赖声明其他业务代码逐步迁走。为了不影响线上发版我建议不要急着改名字而是在原工程基础上做减法保证主干随时可编译。5.2 第二步从基础组件开始逐层抽取组件拆分一定要遵循依赖顺序先从底往上拆。比如先把网络层、存储层、基础UI层拆成独立Pod保证这些组件不依赖任何业务模块。当时我先抽了NetworkModule做法是在GitLab上创建NetworkModule.git仓库。将网络层相关源文件移动到仓库目录。对外暴露统一接口内部实现全部隐藏。执行pod lib lint验证podspec合法。打tag并执行pod repo push MySpecs NetworkModule.podspec发布0.1.0版本。一次只抽一个组件抽完立刻改壳工程的import编译通过后再抽下一个。不要想着一个晚上把所有基础组件全拆完那样工程会长时间处于不可编译状态团队压力很大。5.3 第三步业务模块按“页面-服务-依赖”三件套拆分基础组件稳定后开始拆业务模块。业务模块比基础组件复杂因为里面既有页面又有对外服务还有对下层组件的依赖。我拆业务模块时的固定动作是在GitLab建OrderModule.git仓库。把订单模块的业务文件全部移进去。在模块内实现订单对外服务协议并在启动时注册。把订单页面依赖的用户信息等数据改为通过协议从账号组件获取。处理资源文件放进组件的resource_bundles。拆出来的订单组件需要依赖网络层、基础UI等基础组件但它不需要依赖登录组件更不需要依赖首页、商品这些同级业务组件。如果某个业务组件发现必须依赖另一个业务组件说明边界划得不对要把公共部分继续下沉。5.4 第四步版本管理与发布节奏组件化改造后所有组件都有了独立版本号。壳工程Podfile可以这样锁定依赖platform :ios, 12.0 source https://github.com/CocoaPods/Specs.git source gityour-git-server:ios/MySpecs.git target DemoApp do pod NetworkModule, ~ 1.0.0 pod UserModule, ~ 2.1.0 pod OrderModule, ~ 1.2.0 end~表示允许补丁版本更新但不跨次版本。组件版本管理遵循语义化版本破坏性API用大版本新增功能用次版本修复bug用补丁版本。即使不同版本同时存在CocoaPods也会通过依赖规则自动解析。CI也要配置好组件仓库打tag后触发自动构建、自动lint、自动推送Spec Repo。没有这套自动化靠手动打tagpublish很容易漏。5.5 第五步逐步替换和灰度迁移不用一步到位。壳工程里那些还没拆的旧代码可以暂时留在“待迁移”目录新业务需求直接在新组件或路由方案里做老页面慢慢改。整个过程可以持续几个迭代只要方向明确慢一点没关系。6. 组件化过程中不得不踩的坑6.1 循环依赖一开始就要禁止循环依赖在单体工程里可能不明显但拆成独立Pod后A依赖B、B依赖Apod install时就会直接报错即使不报错运行时也很容易崩溃。处理方式只有一个把循环依赖的部分往下拆。比如A和B都依赖同一份用户模型就把用户模型放到CoreModel组件如果只是方法调用循环就定义协议由上层组合。坚持“下层不知道上层存在”的原则循环依赖就会消失。6.2 资源文件冲突图片、XIB、Assets的归属这个问题几乎每个组件化团队都会遇到。多个组件里有同名的图片或者XIB打包时同名文件会被随机覆盖结果就是“图片显示不对”之类的诡异bug。解决办法是使用resource_bundles并为每个组件设置独立前缀。同时所有组件内部资源都要通过对应bundle读取NSBundle *bundle [NSBundle bundleWithURL:[[NSBundle bundleForClass:self.class] URLForResource:OrderModule withExtension:bundle]]; UIImage *image [UIImage imageNamed:order_placeholder inBundle:bundle compatibleWithTraitCollection:nil];这段代码看起来繁琐但很有必要。你永远不知道你的图片资源会在哪个组件里重名。6.3 组件并行开发的Merge泥潭组件独立仓库能减少不同模块之间的冲突但同一组件内多人并行修改还是会冲突。解决办法是给每个组件设置负责人Code Owner其他成员改这个组件时必须通过负责人review。组件之间不要互相改代码有需求就通过协议和路由申请。团队约定比技术方案更重要。我见过不少团队把组件化当“目录整理”结果拆完之后业务A照样直接改业务B的代码耦合又悄悄回来了。所以制度上要守住底线。6.4 动态库与静态库的混用问题CocoaPods默认使用静态库集成。当你改用动态framework后要注意主工程里同一个类可能被重复编译进多个二进制运行时会暴露“Class XXX is implemented in both”的警告。如果使用静态库则在链接阶段排查重复符号更困难。我的建议是最终App统一采用静态库方式如果真需要动态库只针对极少的热更新/插件场景使用并确保所有组件没有重复符号。否则线上出现莫名其妙的crash排查起来非常痛苦。6.5 Debug体验变差组件二进制化会带来一个很实际的麻烦断点打不进去日志看不到源码。为了调试需要在Debug配置下使用源码版本。我在Podfile里用一个环境变量控制if ENV[USE_BINARY] 1 pod OrderModule, :path ./LocalBinPods/OrderModule else pod OrderModule, :git gityour-git-server:ios/OrderModule.git, :tag 1.2.0 end每次切源码/二进制执行一次pod install其实成本很低。我强烈建议把这种脚本封装成一条命令否则团队会有人因为嫌麻烦直接跑二进制包调试届时拿不到源码日志排查问题效率直线下降。6.6 工具链不熟导致内耗组件化过程中团队成员要习惯Git打tag、pod lib lint、pod repo push这些操作。第一次接触的人很容易漏掉某个步骤尤其是忘记打tag就push spec导致别人安装时找不到对应版本。我整理了一份内部文档把每一次发布组件的步骤用脚本固化下来尽量让团队成员少接触原始命令。另外CI上加了lint校验git tag和podspec版本自动关联漏掉的概率就小多了。最后想说的话组件化改造如果只看架构图会觉得一切都挺简单但真正动手时你会碰到各种工程历史包袱和团队协作问题。我个人在实际改造中最看重的一点是拆分节奏宁可慢也不要有一次合并让主干编译失败。只要主干稳定团队对这套体系的信任就不会崩塌。如果你正在考虑要不要做iOS组件化开发建议先从压缩编译时间和解决最痛的一处线上bug入手让收益能被所有人看见再逐步推进其他模块。毕竟组件化本身不是目的让团队开发更顺畅、让工程质量更可控才是我们真正想要的东西。
返回列表