iOS中的模块化架构
模块化体系结构是软件工程中非常流行的主题。随着整体应用程序的增长,它变得越来越难维护,因此需要将其拆分为单独的模块。在后端是 微服务 ,在Web上是 微前端 。在本文中,我们展示了它如何在 iOS中工作 。
在 上一篇文章中 ,我们了解了如何使用 Clean Architecture + MVVM 创建应用程序。在这里,我们展示了如何通过将应用程序 分离 到 隔离的模块 (例如 NetworkingService,TrackingService,ChatFeature,PaymentsFeature … )来改善您的项目。模块隔离可以帮助团队快速,独立地使用这些模块。
当我们使用诸如 Clean Architecture + MVVM的 良好架构时,单独整体也不错。尽管如此,该应用程序仍可能变得太大而无法独占。并且需要加快构建速度,将可重用的模块作为框架以及隔离的模块,以便人们可以在独立的跨职能团队中轻松工作。
大公司通常将其应用程序分为数十个模块。今年,我们设法将数百万用户的整体式OLX应用程序分为12个模块。现在,我们总是为足够大的新功能创建一个新模块。
我们称之为模块化架构,它也适用于其他平台(例如Android或ReactNative或Flutter之类的跨平台)。在此架构中,该App分为完全独立的功能模块,这些模块依赖共享Shared和核心Core模块。
在本文中,我们将展示如何将单独整体App划分为独立的模块: 网络服务 和 电影搜索功能 。网络服务 模块是在App内部配置的,并注入到 电影搜索功能 :
在本文的结尾,我们将看到如何将其规划到大尺寸,其中每个模块都有自己的 Clean Architecture + MVVM,Domain和DIContainer 。
规划后的模块化架构:
注意 :此图的详细说明在下面的部分: 如何规划模块化架构
模块化架构
模块化 编程 是一种软件设计技术,它强调将程序的功能分成 独立的,可互换的 模块,以便每个模块都包含执行所需功能的一个方面所必需的所有内容。
我们在这里使用CocoaPods作为依赖项管理器,将应用程序拆分为独立的模块。CocoaPods是功能强大的依赖项管理系统,它使框架集成非常容易和方便。我们可以使用的其他依赖项管理器是Swift Package Manager或Carthage。
当功能足够大以至于可以被称为或视为自己的一个产品时,我们将创建一个新的功能模块。它可以由跨职能团队独立开发。
每个模块都有自己的架构。每个团队决定哪种架构最适合其模块开发(例如 Clean Architecture + MVVM ,Redux …)和Domain。这意味着将从模块内部的API提取模块的所有域实体(Domain Entities) ,并将其映射到此处。 注意 :当我们要共享某些域实体(如 用户)时, 我们将创建 CommonDomain 模块。
每个单独的模块功能都将具有其自己的 依赖项注入容器 ,以具有一个 入口点 ,在这里我们可以看到模块的所有依赖关系和注入。
每个模块都有一个示例/演示项目,可单独快速地开发它,而无需编译整个app。
没有主机(host)app,每个模块的测试也将快速运行。它们将与模块源代码一起提供。
重要 要提及的是,模块是 本地 的,这意味着它们作为主App 位于同一repo仓库(Monorepo ), 以名称DevPods在文件夹内 。当我们想与另一个项目共享它们时,仅在单独的repo仓库中将它们设为远程。直到第二个新项目需要使用此模块,这才有意义,然后可以非常轻松地将其移至其自己的repo仓库。
通常,我们也尝试在此架构中尽量减少使用第三 部分框架。如果一个模块需要依赖于第三者框架,我们尝试使用包装器来隔离这种使用,并将这种依赖的使用限制为仅一个模块。我们在下面的一节中的示例中对此进行解释: 初始App规划-使用第三方框架添加身份验证模块(Initial App Scaling -Adding Authentication Module with 3rd party framework)
注意: 在本文中,模块和框架具有相同的含义。因为在Xcode中创建新模块的方法是通过创建新框架。而 DIContrainer ——是一个依赖注入容器。功能模块的 Example App -表示它是该功能模块的 Demo App ,用于开发功能。
模块化架构的优势
构建时间 更快。更改一个功能模块不会影响其他模块。通过重新编译仅更改的模块,该应用程序将更快地编译。编译大型整体app比编译单独部件(干净的版本和增量版本)要慢得多。例如,我们所有app的正常构建时间为:4分钟,而付款模块example/demo app为1分钟。
开发时间 更快。改进不仅在编译速度上,而且在访问我们正在开发的屏幕上。例如,在example项目的模块开发过程中,您可以轻松打开此屏幕,作为app启动时的初始屏幕。当我们在主app中进行开发时,您不需要像通常那样浏览所有屏幕。
变化独立 。在模块中进行开发时,项目中代码区域的职责明确,而在执行合并请求时,很容易看到受影响的模块。
测试可以在几秒钟内运行, 因为它们仍将在没有主机(host)app的情况下运行,它们将位于模块的Pod中。
模块依赖规则:
模块可以相互依赖(没有循环依赖 ),也可以依赖于第三方框架。(例如,不允许依赖项A <-> B)
A-> B-> C表示导入B的模块A也将访问C
当创建一个新模块并且此模块依赖于尚未提取到单独模块中的其他功能时,我们使用代理或闭包将该功能委托给主App。例如,如果物流模块需要向用户显示聊天,我们可以在 物流 模块内部创建代理 func openChat(withUserId:itemId:onView:) ,并在主App内部实现并将其注入到物流模块中。
另一方面 ,如果 功能 已经存在于单独的模块中,则只需在主App内部对其进行配置,然后将其注入到我们要分离的模块中即可。
在 示例项目 上应用模块化架构
在这里,我们将把整体App分为 服务 和 功能(完全隔离的模块)。整体App在 此repo仓库中 。
在将“电影搜索 功能” 移动 到模块中之前, 首先,我们需要将“网络 服务” 移动到一个单独的模块中,因为我们需要使用“网络服务”从该功能中获取电影items。它将使用App中的基本URL和API**进行配置,并注入到电影搜索 功能 模块中(使用DIContainer) 。
服务和功能模块的分离过程:
将网络服务移动到隔离的模块中
首先,我们使用 pod init 和 pod install 命令设置项目。然后,在项目文件夹中创建名称为DevPods 的文件夹。在此文件夹中,我们运行 pod lib create Networking
命令,该命令将使用示例项目来创建模块,该示例项目用于开发此模块的。从主App中移出所有模块的代码并配置Podfile 和module.podspec 文件(并运行 pod install )之后,结果在主App工作区中有一个额外的schema为 Networking-Example
,用于开发此_Networking_ 模块。
现在可以通过选择schema来分别开发网络:
Schemas,项目Pods组,Podfile,Networking.podspec
在这里,我们可以看到运行命令 pod install 时CocoaPods如何将模块文件自动复制到app工作区Pod项目中:
模块文件源和资源位置(工作区Pod项目,文件夹,podspec)
重要提示 :有关模块创建的详细说明,请参见:使用 视频 创建模块的步骤 。 步骤可以从readme.md文件链接到,因此每个开发人员都可以轻松创建一个新模块。
注意: 如果我们现在想与更多的人共享此Networking,并且在许多不同的项目之间轻松在远程仓库中共享它,则: https : //github.com/kudoleh/SENetworking
将电影搜索功能移动到单独的模块中
与 Networking服务 相同,我们 在 DevPods 文件夹中 运行 pod lib create MoviesSearch
,该 文件夹 创建一个带有示例项目的新模块来开发此模块。我们将所有与模块相关的文件移动到该模块中。从主App中移出所有模块的代码并配置 Podfile 和module. podspec 文件(并运行 pod install )之后,结果是在主App工作区中有一个额外的schema为 MoviesSearch-Example
,用于开发此模块(下图)。 注意 :此功能已经具有Clean Architecture和DIContainer,如果没有,我们将在移动它之前将其添加到该功能中。
现在,可以通过选择schema来单独开发电影搜索:
Schemas,项目Pod组,Podfile,MoviesSearch.podspec
在这里,我们可以看到运行命令 pod install 时CocoaPods如何将模块文件自动复制到app工作区Pod项目中:
模块文件源和资源位置(工作区Pod项目,文件夹,podspec)
在App DIContainer内部,我们使用基本URL和API**配置Networking,然后将其注入Movies Search模块中:
重要提示 :有关模块创建的详细说明,请参见:使用 视频 创建模块的步骤 。 步骤可以从readme.md文件链接到,因此每个开发人员都可以轻松创建一个新模块。
注意: 模块是本地的。 它们与主app位于相同的repo(Monorepo)中,因为我们尚未与其他项目共享此模块。
Localizable.string可以位于主app内部。默认情况下,所有模块都使用这些翻译所在的主app包。如果在模块example项目的开发过程中需要它们,则可以引用此文件。
为电影搜索模块创建入口点
很高兴一度看到一个模块具有什么依赖及其提供的什么函数方法(以提供前置的接口,即Facade模式)。我们创建Module 结构体, 并将模块依赖移至此处。
在电影搜索模块内部:
在App内部:
在App的AppMainFlowCoordinator内部:
项目源码 : https : //github.com/kudoleh/iOS-Modular-Architecture 。
初始App规划——使用第三方框架添加身份验证模块
如今,许多应用程序都需要进行身份验证。因此,我们在此处添加Authentication模块。在这里,我们将看到如何从本地模块(身份验证)使用第三方框架( 用于身份验证的Alamofire ),以及如何将此第三方框架的使用限制为仅一个模块。
带有Authentication模块的模块化架构:
在Authentication.podspec文件中,我们添加 s.dependency’Alamofire’
添加第三方框架依赖性
Authentication模块包含通过Alamofire( SessionManager )发出请求的代码,该代码提供了身份验证 功能。(它具有RequestAdapter和RequestRetrier,我们在其中添加访问令牌并刷新它)
注意 :为了不向其他模块或主App公开使用第3方框架(Alamofire),我们创建 AuthNetworkRequest 包装器 。它是 DataRequest(Alamofire类) 的 包装 。 因为当另一个模块调用 func authRequest(:completion : ) -> DataRequest时 ,它依赖于 Alamofire的DataRequest类。 这样,我们将对 Alamofire的 依赖限制为仅一个模块( Authentication )。此外,在此我们省略了 身份验证 配置(带有auth基本URL)的实现以及将用户授权委派给主App的功能(需要登录时)。
在Networking模块的核心中,我们使用网络会话管理器(Network Session Manager)协议来请求数据。我们将使 Authentication模块(Auth Networking Session Manager)遵循该协议 :
所有请求都在Authentication模块内进行调整,令牌被添加到那里,如果失败,它将刷新令牌并重试请求。这样,Networking模块对身份验证一无所知。这是有道理的,因为添加令牌并刷新令牌是身份验证模块的关注点。而且系统中没有其他模块应该知道这些(没有Networking服务,也没有功能模块)。
Authentication遵循Networking并注入到Networking:
注意 :为了从Authentication 模块不依赖于Networking 模块,我们使AuthNetworkSessionManager协议符合NetworkingSessionManager并将其注入到 NetworkingService。
项目来源 : https : //github.com/kudoleh/iOS-Modular-Architecture
模块化架构如何规划
在这里,我们描述了模块化体系结构如何规划的示例,规划的模块化架构:
在此图中,我们具有以下模块:
Configuration module配置 模块 :包含整个应用程序的配置。每个国家和地区(阶段或生产环境)的基本URL和功能标志之类的参数都存储在此处的plist中。例如,主App使用“配置”模块中的基本URL来设置网络服务并将其注入所有功能和服务。每个 功能模块example中 也使用它来轻松配置被功能使用的服务。
Networking Service网络 服务: 围绕网络API请求的简单包装,可以使用参数(例如基本URL)进行配置。它还映射可解码数据。
Authentication Service认证 服务 :通过添加和刷新访问令牌来认证所有网络请求。这依赖于第三方框架 Alamofire 。 注意 :没有其他功能模块依赖于此模块,只有主App通过身份验证URL对其进行配置,并通过遵循Networking协议将其注入到Networking Service中。在所有功能模块中,我们仅需使用Networking Service来请求数据。
Tracking Service跟踪服务 :跟踪服务使从所有模块的跟踪变得容易。它仅公开一个 func track(event:attributes:) 。在模块内部,它具有所有跟踪的实现。作为Networking模块,它在主App中配置并注入到所有模块中。
Account Feature帐户 功能 :管理用户帐户(例如注册,登录或更新密码)。
Utils module工具 模块 :共享通用的实用功能和扩展。
UIComponents module部件 模块 :共享Common UI组件和与UI相关的扩展。
Themes module主题 模块 :共享常见的图像,颜色和字体。 注意 :为简化起见,它可以是UIComponents模块的一部分以减少模块数。
Feature module功能 模块: 具有足够大 功能的 模块 及其自己的 Clean Architecture,Domain和DIContainer 。依靠Networking Service从网络中获取数据并将其映射到domain entities中。一个功能可以依赖于其他功能。例如,我们可能需要通过物流(Delivery)模块开启用户聊天。(上面章节: 模块依赖关系规则 )
Common Domain(Core) module通用域(核心) 模块 :共享通用Use Cases和Domain Entities(例如,具有UserId和它的函数isLogged的User entity,及其操作的Money entity)。由于该模块还包含Use Cases使用的Repositories仓库实现,因此它需要依赖于Networking模块。它还需要依赖 Cache (用于缓存items的第三方框架)或ImagesRepo使用的图像缓存 Kingfisher 。它还可以依赖具有公用功能的Utility模块。
注意 :当我们有两个Apps时,我们可以在它们之间共享共享模块(例如,Networking,Tracking,Utils,UIComponent或共享功能)。
示例项目,包含创建模块的步骤
https://github.com/kudoleh/iOS-Modular-Architecture
依赖关系图
要自动生成依赖关系图,可以使用此工具: https : //pypi.org/project/cocoapods-graph/
结论
它用于将我们的整体App分解为完全独立的部分。它使我们的开发更加轻松快捷,团队可以快速独立地工作
即使您的app还不大,它也可能在不久的将来变得非常大,并且现在已经很容易启动app模块化。
我们为足够大的功能创建一个模块。具有自己的domain域和架构(例如 Clean Architecture + MVVM 或Redux )。
我们 尽可能地 隔离 了 第三方框架 的使用,因此,当框架发生变化时,其他模块也不会发生变化。
无论依赖管理工具或 平台如何 (例如Android或像 ReactNative或Flutter之 类的跨平台),模块化架构都同样适用。
使用模块化架构的 优点 :
- 更快的 构建时间 (干净的和增量的)
- 在模块内 与domain域相关的逻辑中 更快地定位代码
- 良好的 解耦 和 关注分离
- 更容易进行 代码复核 。很容易看出独立的模块中发生了什么变化
- 单独创建组件更快,也可以在example app中对其进行测试,然后再与主app程序集成
- 便于新开发者 上船 ,并创造新的团队将在一个独立的模块领域工作
- 保持所有 模块的example项目 始终可构建很重要。我们可以使用 Travis CI + Fastlane之 类的工具
- 每个隔离的模块功能都有其自己的 依赖注入容器 和 模块依赖关系, 以便在一处查看其所需的所有依赖关系
- 可以在repo中以编译后的flameworks的形式构建和共享模块,以隐藏模块代码
- 一个 宏标志 可使用包括,或不是模块,到目标(国家)
- 模块的 Unit Tests 运行速度非常快 ,因为它们无需主机(host)app即可运行(无需模拟器),并且位于模块的pod内