前言
最近利用空闲时间再次学习了一下 iOS 中的 组件化/模块化。之前虽说关于组件化的文档,教程看了很多,但一直都处于只懂原理不懂实际使用的情况,这次也是终于搞出来了。
- 我是使用 Cocoapods 通过路由的方式做的组件化。
- 实现了页面的跳转,传参回调,路由错误监听等几个小功能。
- 使用起来也是比较方便的,后边会放上项目的Demo。
该项目方案是我在学习组件化时产出的方案,将来会在我个人的项目中使用该方案。可能会有一些暂时没有考虑到的地方,也会在未来使用时进行优化。
希望能给广大学习人员提供一些思路。
文章较长,如果您能把文章看完,也许产生一些启发。 如果您发现文章中的错误或方案中的不足,可以在评论区指出。
我个人对组件化的理解
- 首先,组件化也可以理解为模块化。
- 我们通过私有库的方式,将项目中的页面,功能等拆分出来制作成组件。
- 之后我们再将多个组件进行拼装,实现一个模块
- 最后将多个模块组装后变成一个完成的App。
这里我画了一个组件化前和组件化后项目的导入文件路径图(画的不准确请见谅) 组件化前
组件化后
组件化后每个模块都是独立开的,通过路由的方式跳转到其他页面,不会出现相互直接使用的方式。(这里有个小错误,第一个模块A服务,后边应该是B服务,C服务,忘记改了问题不大)
我认为做组件化的目的:
- 在多人开发时可以更加方便,每个人只需要在自己的模块上进行代码的编写即可,合并代码时不容易造成冲突。
- 页面跳转时不需要引入其他的库或页面,降低耦合度。
- 封装过后的模块与组件可以更方便的复用。
当然组件化也是有一些缺点的
- 需要有详细的文档,需要标明页面名称,功能,参数,甚至跳转方式与动画。
- 需要检查是否有相同的路径,避免页面冲突。
- 会增加团队间的交流等。
废话不多说直接进入正题 在观看以下内容前,需要学习 Cocoapods 私有库的搭建与使用,网上教程很多就不细说了。
该项目是可以运行的,运行项目可以更清晰的了解页面跳转的逻辑。
打开已经下载好的项目,该项目是 App 的主项目,可以理解为多个模块的壳。 结构并不复杂,代码也尽量精简过了,阅读起来不会很难,并且会在下边进行讲解。
首先看一下 Podfile 文件
source "https://gitee.com/fa_dou_miao/private-podspec.git" source 'https://github.com/CocoaPods/Specs.git's # platform :ios, '9.0' target 'AppDemo' do # Comment the next line if you don't want to use dynamic frameworks use_frameworks! # Pods for AppDemo pod 'A_Moudle' pod 'B_Moudle' end两个 Source 一个是私有组件的索引地址,另一个是 Cocopods 的索引地址。
接着就是对两个私有模块的引入 这里我使用 A, B 两个模块进行举例。
如果不方便理解,可以将 A 想象为登录模块,其中登录模块包括登录页,注册页,忘记密码页等多个页面。
B 模块可以想象为设置模块,包括设置页,关于页,退出登录页。这样可以方便理解
MyRouter 然后我们看一下最主要的路由模块 MyRouter (名字可以在编写时起适合的名字) MyRouter 中包含两个文件
先看 【MyRouter.swift】 文件
import Foundation //MARK: - 模块协议 public protocol RouterMoudleProtocol { /// 模块名称 var moudle: String { get } /// 标识 var scheme: String { get } /// 路由列表 [path: className] var pathDic: [String: String] { get } } public extension RouterMoudleProtocol { /** 默认注册方法 */ func registerPages() { 通过该方法,将自定义模块中的 pathDic 注册(保存)到 MyRouter 单例中, 之后才可以通过路径查找对应的页面进行跳转 MyRouter.shared.registerMoudle(moudle, scheme: scheme, pathDic: pathDic) } }首先是一个模块协议,在创建其他模块时只需要实现该协议即可,可以对照下边的例子阅读
以下是实现模块的方法,以 A 模块的 【A_Moudle.swift】 文件举例
该文件可以理解为是该模块的模块服务部分,主要职责是为路由和子页面建立索引路径,可以想象为一个模块中间件或模块目录这样子
import Foundation import MyRouter import OtherMoudle //MARK: - 模块A public class A_Moudle: RouterMoudleProtocol { 1.模块命名空间的名称:必须与当前模块名称相同,也就是与 import 时的名称相同 public var moudle: String { "A_Moudle" } 2.模块标识:可以随意填写,只要不与其他模块冲突即可 public var scheme: String { "apps" } 3.路径字典:存储着路径与页面类名的对应关系, 每次增加新页面都需要在 pathDic 中添加对应的 path 与 className public var pathDic: [String: String] { ["pathA":"A_Controller", "pathA_Detail":"A_DetailController"] } 4.可以理解为在使用时,通过 url 拿到对应类的字符串名称,再将该名称转换为对应的类。 5.使用举例 "apps://pathA" 或 "apps://pathA_Detail" 将会跳转到对应页面或拿到对应的控制器 6.路径注册的类方法 public class func registerPages() { 因为在 Swift 中是不允许重写 load() 方法的,所以必须在主项目中导入该模块手动注册。 封装该方法也是为了在注册时更方便一些,可以直接通过类方法注册。 当然也可以直接使用下面的方法在主项目中注册。 查看【AppDelegate.swift】文件了解注册方式。 A_Moudle().registerPages() } }
继续查看 MyRouter类 属性部分
//MARK: - 路由 public class MyRouter: NSObject { 单例方法 public static let shared = MyRouter() 错误通知:在跳转页面并且找不到对应的页面时会进行通知,监听该通知可以进行一些自定义操作, 例如弹出错误页面。查看【AppDelegate.swift】文件了解错误监听。 public static let routerErrorNotificaiton = "RouterErrorNotificaiton" // [scheme: [path: className]] 标识字典:通过模块标识获取对应的模块路径字典 private lazy var schemeDic = [String: [String: String]]() // [scheme: moudleName] 模块字典:通过模块标识获取对应的模块命名空间名称 private lazy var moudleDic = [String: String]() }这里通过 schemeDic 存储对应模块的路径,而不是将所有的路径存在一起。
因为考虑到一个项目中可能有多个模块,大的项目甚至有六七十甚至上百个模块。而字典的本质是哈希表,随着内容的增多,可能会出现哈希值重复的情况,从而降低查找效率。所以将不同的模块分开存储,降低查找压力。
虽然不知道是不是多虑了,但多考虑一下总是没错的。 并且分开存储也可能对后期路由功能的扩展产生一些帮助。
继续查看 MyRouter类 公共方法部分
//MARK: - Public Action public extension MyRouter { /** 模块注册 - 模块注册调用该方法 - parameter moudle: 模块名称 - parameter scheme: 标识 - parameter pageClassName: 页面名称 */ 这里是模块注册部分,将对应模块的路径,标识进行存储 func registerMoudle(_ moudle: String, scheme: String, pathDic: [String: >String]) { if moudleDic[scheme] == nil { moudleDic[scheme] = moudle } if schemeDic[scheme] == nil { schemeDic[scheme] = [String: String]() } schemeDic[scheme] = pathDic } /** 获取控制器 - parameter url: 路由 - parameter parameters: 传参 - parameter callBackParameters: 目标参数回调 - returns: 返回一个 UIViewController 控制器 */ 这里是获取控制器的部分,通过传入 url 配合参数和回调获取对应的控制器 func viewController(_ url: String, parameters: [String : Any]? = nil, callBackParameters: (([String: Any]) -> Void)? = nil) -> UIViewController? { guard let decoude = decoudeUrl(url), let moudle = moudleDic[decoude.scheme], let className = schemeDic[decoude.scheme]?[decoude.path] else { decoudeUrl() 方法先对 url 进行解码, 如果解码失败,拿不到对应的模块名称或路径对应的类名,则直接返回 nil return nil } 如果拿到了对应的类名,则转换为 UIViewController if let pageClass = MyRouter.moudleAnyClass(moudle, className: className), let pageType = pageClass as? UIViewController.Type { 然后通过对 UIViewController 的扩展,拿到对应的控制器 return pageType.routerController(parameters, callBackParameters: callBackParameters) }else { return nil } } /** 发送路由跳转错误通知 */ 当跳转页面时找不到对应页面,则会发送通知 func postRouterErrorNotification() { NotificationCenter.default.post(name: .init(MyRouter.routerErrorNotificaiton), object: nil, userInfo: nil) } }
继续查看 MyRouter类 私有方法部分
//MARK: - Private Action private extension MyRouter { /** 对 url 进行解码 - parameter url: url - returns: (scheme: 标识, path: 路径)? */ 对 url 进行解码,我这里就分割了一下字符串,验证方式比较简单,实际可以做的更复杂些 func decoudeUrl(_ url: String) -> (scheme: String, path: String)? { let urlAry = url.components(separatedBy: "://") guard urlAry.count >= 2 else { return nil } return (urlAry[0], urlAry[1]) } }
继续查看 MyRouter类 其他方法部分
//MARK: - OtherAction public extension MyRouter { /** 通过类名获取一个类 - parameter moudleName: 模块名称 - parameter className: 类名称 */ 这里就是通过命名空间与类名的拼接,将字符串转换为对应的类 AnyClass class func moudleAnyClass(_ moudleName: String, className: String) -> AnyClass? { var frameworksUrl = Bundle.main.url(forResource: "Frameworks", withExtension: nil) frameworksUrl = frameworksUrl?.appendingPathComponent(moudleName) frameworksUrl = frameworksUrl?.appendingPathExtension("framework") guard let bundleUrl = frameworksUrl else { return nil } guard let bundleName = Bundle(url: bundleUrl)?.infoDictionary?["CFBundleName"] as? String else { return nil } return NSClassFromString(bundleName + "." + className) } }以上就是整个 MyRouter 文件的代码,主要功能是路径的存储和控制器的获取。
这时候可能会发现并没有实现页面跳转的方法。
因为如果在 MyRouter 模块 中实现了跳转方法,那么在使用的时候,其他模块的子页面就需要引入 MyRouter 模块 来进行跳转。
就算通过模块服务文件**【即 A_Moude.swift文件】**进行一层封装,在使用时也需要通过【模块服务类】进行使用。
为了更加的方便,我选择使用扩展的方式对路由跳转功能进行封装。
继续阅读 MyRouter 控制器扩展 【ExtensionController.swift】 文件
首先是页面跳转部分
import Foundation //MARK: - 跳转页面 extension UIViewController { /** 返回到上一个页面 */ 返回到上一个页面,由于模块是分割开的,有些页面可能即支持 push 跳转,又支持 present 跳转, 所以需要进行判断跳转方式再进行返回, 在返回上一页时控制器直接调用该方法即可 【self.dismissRouterController(animated: true)】 @objc open func dismissRouterController(animated: Bool) { let children = self.navigationController?.children if children?.count ?? 0 > 1 && children?.last == self { self.navigationController?.popViewController(animated: animated) }else { self.dismiss(animated: animated, completion: nil) } } /** 通过 url 的方式 present 一个控制器 - 可重写:重写需要在最后调用 super 方法实现跳转 - parameter url: 路由 - parameter parameters: 可选参数 - parameter animated: 是否执行动画 - parameter callBackParameters: 目标参数回调 */ 通过 url 的方式 present 到下一个页面 @objc open func presentRouterControllerWithUrl(_ url: String, parameters: [String: Any]? = nil, animated: Bool = true, callBackParameters: (([String: Any]) -> Void)? = nil) { presentRouterController(MyRouter.shared.viewController(url, parameters: parameters, callBackParameters: callBackParameters), animated: animated) } /** 通过 viewController 的方式 present 一个控制器 - 可重写:重写需要在最后调用 super 方法实现跳转 - parameter viewController: target viewController - parameter animated: 是否执行动画 */ 通过 viewController 的方式 present 到下一个页面 (考虑到有些情况可能会先拿到控制器进行一些操作再跳转,所以可以使用该方法进行跳转) @objc open func presentRouterController(_ viewController: UIViewController?, animated: Bool = true) { guard let vc = viewController else { // 找不到控制器,发送错误通知 MyRouter.shared.postRouterErrorNotification() return } present(vc, animated: animated, completion: nil) } /** 通过 url 的方式 push 一个控制器, 需要带有 navigationController - 可重写:重写需要在最后调用 super 方法实现跳转 - parameter url: 路由 - parameter parameters: 可选参数 - parameter animated: 是否执行动画 - parameter callBackParameters: 目标参数回调 */ 通过 url 的方式 push 一个控制器 @objc open func pushRouterControllerWithUrl(_ url: String, parameters: [String: Any]? = nil, animated: Bool = true, callBackParameters: (([String: Any]) -> Void)? = nil) { pushRouterController(MyRouter.shared.viewController(url, parameters: parameters, callBackParameters: callBackParameters), animated: animated) } /** 通过 viewController 的方式 push 一个控制器, 需要带有 navigationController - 可重写:重写需要在最后调用 super 方法实现跳转 - parameter viewController: target viewController - parameter animated: 是否执行动画 */ 通过 viewController 的方式 push 一个控制器 @objc open func pushRouterController(_ viewController: UIViewController?, animated: Bool = true) { guard let navigationController = self.navigationController else { // 找不到 navigationController 发送错误通知 MyRouter.shared.postRouterErrorNotification() return } guard let vc = viewController else { // 找不到控制器,发送错误通知 MyRouter.shared.postRouterErrorNotification() return } navigationController.pushViewController(vc, animated: animated) } }以上是页面跳转的部分,因为在【模块服务文件】必须引入MyRouter,所以模块内的子页面都可以通过扩展的方法进行页面跳转,不需要再使用【模块服务类】或 【路由类】
继续阅读 MyRouter 控制器扩展 【ExtensionController.swift】 文件
接下来是控制器获取部分
//MARK: - 获取控制器 extension UIViewController { /** 返回当前的控制器 可通过重写该方法,对传入的参数进行初始化,赋值等操作 - 可重写:重写不需要调用 super 方法 - parameter parameters: 可选参数 - returns: 返回一个 UIViewController 控制器 */ 首先是返回当前的页面。 当其他模块通过 url 获取当前页面时会调用该方法。 默认情况是不需要传参直接创建一个自己页面的对象进行返回。 如果当前页面需要传参才可以使用的话,可以重写该方法, 然后对 parameters (传过来的参数进行操作),然后再判断是否应该返回当前控制器 @objc open class func routerController(_ parameters: [String: Any]? = nil, callBackParameters: (([String: Any]) -> Void)? = nil) -> UIViewController? { // 返回一个控制器 return self.init() } /** 通过 url 获取目标控制器 - parameter url: 路由 - parameter parameters: 传参 - parameter callBackParameters: 目标参数回调 - returns: 返回一个 UIViewController 控制器 */ 有时会需要拿到一个其他模块的控制器,但是不需要跳转。 可以通过该方法进行目标控制器的获取 @objc open func viewController(_ url: String, parameters: [String : Any]? = nil, callBackParameters: (([String: Any]) -> Void)? = nil) -> UIViewController? { MyRouter.shared.viewController(url, parameters: parameters, callBackParameters: callBackParameters) } }举例:页面传参以及参数回调的使用 以下代码来自【A_Controller.swift】文件
//MARK: - Action extension A_Controller { /** 点击跳转按钮 */ @objc private func clickButton() { let datailParameters: [String: Any] = ["id": "id123", "name": "name123", "image": UIImage()] self.pushRouterControllerWithUrl("apps://pathA_Detail", parameters: datailParameters, animated: true) { parameters in // 页面参数回调 print("==========") print("页面参数回调") print("当前页面: A_Controller") print("参数来自: apps://pathA_Detail") print("参数内容: \(parameters)") print("==========") } } }举例:传参获取以及参数回调的使用 以下代码来自【A_DetailController.swift】文件
//MARK: - Action extension A_DetailController { /** 重写该方法进行参数获取 - parameter parameters: 传入的参数 - parameter callBackParameters: 数据回调 */ public override class func routerController(_ parameters: [String : Any]? = nil, callBackParameters: (([String : Any]) -> Void)? = nil) -> UIViewController? { if let id = parameters?["id"] as? String, let name = parameters?["name"] as? String{ // 拿取参数 // 可以自定义初始化传参的方式 let vc = A_DetailController(id: id, name: name) vc.image = parameters?["image"] as? UIImage // 在需要的地方通过 callBackParameters 进行回调传参 vc.callBackParameters = callBackParameters return vc }else { // 拿不到必要参数,返回空页面 // 当 Router 收到空页面时,会在 present 或 push 的时候发送错误通知,并停止跳转 // 如果 app 监听了错误通知,可以手动弹出一个错误页面 return nil } } }
路由模块总结 以上就是整个路由模块部分了,该模块主要功能包含路径存储,页面获取与跳转,错误通知。
其实还有很多值得优化的地方,例如路由解码部分,可以封装一个 web 解析页面,当检测到传入的路径为 webUrl 的情况时,自动跳转到 web 页面。
也可以对 url 解析做的更复杂一些,根据实际的情况对路由模块进行补充与扩展。
关于第三方库的使用 那么一个项目,除了自己写的页面外,还会使用第三方工具。 一般在项目中我们会直接在主工程中引入第三方库,然后对该库进行封装然后使用。
那么在组件化中如何保持解耦的情况下还能使用第三方库呢 我想到的方法是二级封装。
如何理解二级封装 我们以 Alamofire 举例:
- 先创建一个公共库模块,也就是项目中的**【OtherMoudle(名字为了演示用,请根据实际功能起名)】**
- 通过**【OtherMoudle】**引入 Alamofire ,并对其进行封装。
【以下是 OtherMoudle 中的 NetworkManager.swift 文件】
// // NetworkManager.swift // OtherMoudle // // Created by 发抖喵 on 2022/1/30. // // 为网络请求进行一级封装,仅供演示 // 在使用前需要模块服务导入该组件,并对组件进行继承,甚至二级封装使用 import Foundation import Alamofire open class NetworkManager: NSObject { open func request(_ url: URLConvertible) { AF.request(url) // 演示代码 } }
- 我们对网络请求的功能进行了一层封装(一级封装)。 也就是说,所有的业务模块,都需要引入该模块,才能使用网络请求的功能。
然后我们到业务模块中
业务模块想使用网络请求功能怎么办呢? 【以下是 A_Moudle 中的 A_Moudle.swift 文件】
//MARK: - 演示用途, 可进行二次封装, 单独为该模块进行封装 class A_ModuleNetWorkManager: NetworkManager { static let shared = A_ModuleNetWorkManager() }我们不能直接使用 NetworkManager,而是对 NetworkManager 再封装一层,也就是二级封装
- 于是我们就可以在自己模块中使用属于自己的网络请求功能,而不需要再次引入 【OtherMoudle】模块。
- 并且,我们可以根据模块的需求,在父类的基础上自定义属于自己的网络请求方式。
- 其他的库也是如此,在第三方库模块中进行一级封装。在需要使用的【模块服务】文件中进行二级封装。
- 如果不需要自定义,直接继承即可。如果需要自定义,继承后再根据需求进行重写等。
基础模块也是如此 通过对基础模块(常量值等)进行一级封装 再在需要使用的【模块服务】文件中进行二级封装即可使用
####大体功能与逻辑解释的差不多了,接下来是实际项目中的使用流程
- 创建一个新的项目(或对已有项目的功能进行拆分)
创建基础模块(常量,key等)
创建基础工具模块(网络请求库,弹窗库等)
创建业务模块,必须引入路由模块并实现协议,其他需要啥引入啥(例如A_Moudle 或 B_Moudle)
在主项目中 pod 业务模块,注意添加 source
在主项目中的 AppDelegate 文件中,引入每一个业务模块并进行注册(未注册的模块是无法找到对应路径的)
监听路由错误的通知,并进行自定义操作。
可以通过 url 获取对应的控制器创建 rootViewController
// // AppDelegate.swift // AppDemo // // Created by 发抖喵 on 2022/1/27. // import UIKit import MyRouter import A_Moudle import B_Moudle @main class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { // register A_Moudle.registerPages() B_Moudle.registerPages() // 监听路由错误通知 NotificationCenter.default.addObserver(self, selector: #selector(routerErrorNotification), name: .init(MyRouter.routerErrorNotificaiton), object: nil) // rootVC window = UIWindow(frame: UIScreen.main.bounds) window?.rootViewController = rootController() window?.makeKeyAndVisible() return true } /** 演示用 */ func rootController() -> UIViewController { if let a_VC = MyRouter.shared.viewController("apps://pathA"), let b_VC = MyRouter.shared.viewController("bpps://path/b") { let tabVC = UITabBarController() tabVC.addChild(UINavigationController(rootViewController: a_VC)) tabVC.addChild(UINavigationController(rootViewController: b_VC)) tabVC.tabBar.tintColor = .orange tabVC.tabBar.unselectedItemTintColor = .gray return tabVC } return UIViewController() } @objc func routerErrorNotification() { print("收到错误信息, 弹出一个错误页面") window?.rootViewController?.present(ErrorViewController(), animated: true, completion: nil) } }
总结
好了,以上就是我这段时间对组件化学习的总结了。
方案并不完美,希望能对想学组件化的你产生一些启发。
如果你有什么疑问或有更好的方式,都可以在评论区告诉我哦。
码字不易,给个赞吧。