Swift 组件化方案

2,174 阅读16分钟

前言

最近利用空闲时间再次学习了一下 iOS 中的 组件化/模块化。之前虽说关于组件化的文档,教程看了很多,但一直都处于只懂原理不懂实际使用的情况,这次也是终于搞出来了。

  • 我是使用 Cocoapods 通过路由的方式做的组件化。
  • 实现了页面的跳转,传参回调,路由错误监听等几个小功能。
  • 使用起来也是比较方便的,后边会放上项目的Demo。

该项目方案是我在学习组件化时产出的方案,将来会在我个人的项目中使用该方案。可能会有一些暂时没有考虑到的地方,也会在未来使用时进行优化。

希望能给广大学习人员提供一些思路。

文章较长,如果您能把文章看完,也许产生一些启发。 如果您发现文章中的错误或方案中的不足,可以在评论区指出。

我个人对组件化的理解

  • 首先,组件化也可以理解为模块化。
  • 我们通过私有库的方式,将项目中的页面,功能等拆分出来制作成组件。
  • 之后我们再将多个组件进行拼装,实现一个模块
  • 最后将多个模块组装后变成一个完成的App。

这里我画了一个组件化前和组件化后项目的导入文件路径图(画的不准确请见谅) 组件化前 组件化前

组件化后 组件化后 组件化后每个模块都是独立开的,通过路由的方式跳转到其他页面,不会出现相互直接使用的方式。(这里有个小错误,第一个模块A服务,后边应该是B服务,C服务,忘记改了问题不大)

我认为做组件化的目的:

  • 在多人开发时可以更加方便,每个人只需要在自己的模块上进行代码的编写即可,合并代码时不容易造成冲突。
  • 页面跳转时不需要引入其他的库或页面,降低耦合度。
  • 封装过后的模块与组件可以更方便的复用。

当然组件化也是有一些缺点的

  • 需要有详细的文档,需要标明页面名称,功能,参数,甚至跳转方式与动画。
  • 需要检查是否有相同的路径,避免页面冲突。
  • 会增加团队间的交流等。

废话不多说直接进入正题 在观看以下内容前,需要学习 Cocoapods 私有库的搭建与使用,网上教程很多就不细说了。

点击进入 Demo 页面进行下载

该项目是可以运行的,运行项目可以更清晰的了解页面跳转的逻辑。

打开已经下载好的项目,该项目是 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.png

先看 【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】模块。
  • 并且,我们可以根据模块的需求,在父类的基础上自定义属于自己的网络请求方式。
  • 其他的库也是如此,在第三方库模块中进行一级封装。在需要使用的【模块服务】文件中进行二级封装。
  • 如果不需要自定义,直接继承即可。如果需要自定义,继承后再根据需求进行重写等。

基础模块也是如此 通过对基础模块(常量值等)进行一级封装 再在需要使用的【模块服务】文件中进行二级封装即可使用

####大体功能与逻辑解释的差不多了,接下来是实际项目中的使用流程

  1. 创建一个新的项目(或对已有项目的功能进行拆分)image.png
  1. 创建基础模块(常量,key等)

  2. 创建基础工具模块(网络请求库,弹窗库等)image.png

  3. 创建业务模块,必须引入路由模块并实现协议,其他需要啥引入啥(例如A_Moudle 或 B_Moudle)image.png

  4. 在主项目中 pod 业务模块,注意添加 sourceimage.png

  5. 在主项目中的 AppDelegate 文件中,引入每一个业务模块并进行注册(未注册的模块是无法找到对应路径的)

  6. 监听路由错误的通知,并进行自定义操作。

  7. 可以通过 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)
   }
}


总结

好了,以上就是我这段时间对组件化学习的总结了。

方案并不完美,希望能对想学组件化的你产生一些启发。

如果你有什么疑问或有更好的方式,都可以在评论区告诉我哦。

码字不易,给个赞吧。