iOS组件化【Swift组件化方案】

寂静海岸(加载图).jpg

更新时间:2022-6-22
增加了参数回调的说明,并列举可以通过字典方式传递闭包然后进行参数的回调。

更新时间:2022-9-1
补充子模块的.podspec文件内容。

更新时间:2022-10-9
目前正在独立开发一款APP,准备使用这套组件化方案,预计明年上线,上线后我会更新实际项目使用中遇到的问题以及对组件化新的理解。

更新时间:2024-3-14
独立设计开发的APP,万事口袋1.0.0第一次提交审核。

更新时间:2024-3-29
万事口袋App 1.0.0 版本已上线全球,手机 App Store。

更新时间:2024-5-13
万事口袋App 1.1.0 已更新,支持了 iCloud 同步以及英文

接下来准备编写关于swift组件化实际项目的总结。

前言

文字内容较多,内容较为枯燥,请做好准备

最近利用空闲时间搞了一下 Swift 的组件化/模块化。
该方案是学习和设计的组件化方案雏形,包含完整的Demo和文档。
如果你对该方案有什么问题或见解,欢迎评论或私信。

  • 使用 Cocoapods 通过路由的方式做的组件化。
  • 实现了页面跳转,复杂对象/图片等的传参与回调,错误监听等功能。
  • 使用起来也比较方便,下边已放上组件化的Demo。
  • 支持第三方 OC/Swift 库等的使用。

如果发现有哪些没有考虑到的地方,还请指出。

希望能给想要了解组件化的同学提供一下思路。


文章较长,请备好瓜子,可乐。

我个人对组件化的理解

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

组件化后每个模块都是独立开的,通过路由的方式跳转到其他页面,不会出现相互直接使用的方式。

我认为做组件化的目的:

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

我认为关于复用的部分,我的理解是做了组件化以后才做一部分功能的复用,而不是为了复用而做的组件化。


1.可以假设一个场景,例如你正负责直播模块的业务。
2.这时有了一个新的需求,需要在直播模块增加一个新的礼品页面,但是礼品的详情页使用另一个模块的详情页(详情页正在商品模块进行开发)。


3.正常情况下,如果你要开发,就需要先把自己的页面写好并且等待同事将详情页写好,然后合并代码后,将跳转详情页的地方改成对应的类名。
4.在使用了组件化后,你写完自己的模块,跳转的页面直接使用 path。如果没有bug后续就不需要管了。虽然详情页正在开发,但是对方只要在文档中提供了路径和参数,你就可以实现跳转。


5.而在主项目中,直接将直播模块进行升级即可,也不需要进行合并,降低了合并冲突的概率。
6.并且,正常情况下,如果详情页出现bug,那么对方将bug修复完成后,你也需要将代码拉取,合并等。如果跟你有关系的话你可能也需要改动。但是使用了组件化后,对方出现什么bug,也只是对方模块上的问题。只要不需要改参数,不需要改 path,自己的模块都不需要改动,并且改动完只需要将模块升级即可。


7.所以说降低了包括合并,交流,bug修改,职责分配等一系列的问题。
8.然后在组件化的基础上,对复用较多的业务,组件,逻辑等进行抽离。从而形成公共组件(所以组件复用率的高低并不是评判组件化是否必要的方式)。


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

  • 需要有详细的文档,需要标明页面名称,功能,参数,甚至跳转方式与动画。
  • 需要检查相同模块是否有相同的路径,避免页面冲突。
  • 会增加团队间的交流等(这里的交流指的是在需求研讨期间,对参数,路径,跳转方式等的交流。当进入到开发阶段,基本上就不需要交流了)。
  • 还有一个缺点就是组件库会比较多,有时可能一个文件也需要搞一个组件库,目的是为了将他们的类别分清楚,同时也会间接的增加一些工作量。

废话不多说直接进入正题
在使用组件化时,需要学习 Cocoapods 私有库的搭建与使用,可以看我另一篇文章 私有库的搭建
没有学会私有库搭建话,也可以先往下看了解下组件化思路。


点击进入 Demo 页面进行下载【2022-3-8 更新对 OC 库的使用示例】
该项目是可以运行的,运行项目可以更清晰的了解页面跳转传参的逻辑。

注意:请使用iOS12.4及以上的真机或模拟器运行。
如果出现找不到第三方库的情况,先执行

pod repo add https://gitee.com/fa_dou_miao/private-podspec.git

然后在【Podfile】文件中注释掉两个 pod,执行 pod install,然后再去掉注释执行 pod install。(相当于重新安装)

项目无法进行断点调试是因为关闭了Debug executable,可以在Edit Scheme...中打开。


打开已经下载好的项目,该项目是 App 的主项目,可以理解为多个模块的壳。
结构并不复杂,代码也尽量精简过了,阅读起来不会很难,下边也会进行细致的讲解。

首先看一下主项目的 Podfile 文件

source "https://gitee.com/fa_dou_miao/private-podspec.git"
source 'https://github.com/CocoaPods/Specs.git'
# 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 {
   /**
    点击跳转按钮
    实际上,可以不使用演示中这种参数回调的方法,因为闭包是可以通过字典传递的。
    例如,
    let callBack: ((Int) -> Void) = { value in
        print("闭包传递返回:\(value)")
    }
    let datailParameters: [String: Any] = ["block": callBack],通过这种方式将闭包传过去,然后对方再调用闭包也可以实现参数的回调,代码中只是其中一种方式。
    */
   @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{ // 拿取参数
           
           // 可以自定义初始化传参的方式
           // 不建议使用 init 传参,如果通过 init 传参一定要重写该方法,否则跳转时会崩溃
           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
  1. 创建业务模块,必须引入路由模块并实现协议,其他需要啥引入啥(例如A_Moudle 或 B_Moudle)【补充:这里的 pathDic 类型也可以使用 [string: AnyClass],好处是在不需要通过字符串转换类,也就不需要手写 moudle 属性了,缺点是占用内存比字符串更多,因为这是需要注册常驻在内存中,类占用的内存是大于字符串的】
    image.png
  1. 在主项目中 pod 业务模块,注意添加 source
    image.png
  1. 在主项目中的 AppDelegate 文件中,引入每一个业务模块并进行注册(未注册的模块是无法找到对应路径的)

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

  3. 可以通过 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 ErrorViewController()
   }
   
   @objc func routerErrorNotification() {
       print("收到错误信息, 弹出一个错误页面")
       window?.rootViewController?.present(ErrorViewController(), animated: true, completion: nil)
   }
}


总结

好了,以上就是我对组件化的总结了,希望能对想学组件化的同学有帮助


方案可能会有考虑不周或不是最优方法的情况,如果你有疑问或更好的方式,都可以提出来,非常感谢!


🥺🥺🥺🥺🥺如果觉得有用就点个赞吧,你的赞是我最大的动力🥺🥺🥺🥺🥺

关于图片资源的补充


  • 日常开发中经常会用到图片,图标等资源。
  • 在使用组件化之前我们的大部分图片都是存放在【Assets.xcassets】中。
  • 在使用组件化之后,可以将图片放到每个模块的资源文件中。(但是这样会造成不同模块使用了相同图片的问题,从而导致打包后文件过大)

  • 我个人认为可以将图片抽成一个基础组件,并增加一个和页面文档相同的图标文档。

  • 该文档由设计负责分类,命名。


  • 每次在设计的时候,先到图标文档查找是否有合适的图标。
  • 如果有的话在文档中标出已有图标名称,如果没有的话再设计新图标添加到文档中
  • 并告知负责该模块的开发人员添加新图标并更新库(命名与文档中命名相同)。

  • 在使用的时候,其他模块只需要引用该图标模块即可,不会造成图片重复等问题(封装库时需要注意图片库的 Bundle 名称问题),以及图片在私有库中可以直接使用文件的方式加载但需要注意区分@2x与@3x的文件,也可以使用【xcassets】资源文件的方式加载,但是需要在【podspec】文件中设置【resource_bundles】参数)。

关于其他组件与 OC 库的引用


一个项目中除了正常的业务组件,还有有用户信息,产品配置文件等。

  • 对于基本配置文件,如果该文件或者说是该模块不会在业务模块中用到,怕麻烦的话是可以直接放到主工程中的。这类文件一般会在 APP 启动时使用,之后很少改动也不需要在其他业务中使用。

  • 对于用户本地信息这类部分,我个人是实现一个单例类,将其定义为用户配置组件。因为这类组件在业务中会使用到。例如展示用户名,修改用户信息等。

  • 私有库除了 Swift 文件一定还需要引用一些 OC 的库或文件,对于 pod OC 的库,直接引用并使用 Swift 类对主要使用的 OC 类进行继承即可,对于自己创建的 OC 文件,因为可能无法继承,所以创建一个新的类将 OC 类作为一个属性存储使用即可(目前是这样的,找到更好的方法话会更新文档)

  • 我的理解就是在组件化中,万物皆组件。制作组件的目的是以提高开发效率为主,其次是模块的复用。

获取自定义 View 的方式

除了 UIViewController 可以通过扩展的方式获取以外,自定义的 View 也可以通过相同的方式获取,代码都是相同的我就不再写例子了,大家举一反三一下就可以。

补充子模块的.podspec文件内容

A_Moudle'

#
# Be sure to run `pod lib lint A_Moudle.podspec' to ensure this is a
# valid spec before submitting.
#
# Any lines starting with a # are optional, but their use is encouraged
# To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html
#

Pod::Spec.new do |s|
 s.name             = 'A_Moudle'
 s.version          = '0.3.7'
 s.summary          = '组件化模块A'

# This description is used to generate tags and improve search results.
#   * Think: What does it do? Why did you write it? What is the focus?
#   * Try to keep it short, snappy and to the point.
#   * Write the description between the DESC delimiters below.
#   * Finally, don't worry about the indent, CocoaPods strips it!

 s.description      = <<-DESC
TODO: Add long description of the pod here.
                      DESC

 s.homepage         = 'https://gitee.com/xxxxxx/a_moudle'
 # s.screenshots     = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2'
 s.license          = { :type => 'MIT', :file => 'LICENSE' }
 s.author           = { 'trembleCat' => 'xxxxxx@163.com' }
 s.source           = { :git => 'https://gitee.com/xxxxxx/a_moudle.git', :tag => s.version.to_s }
 # s.social_media_url = 'https://twitter.com/<TWITTER_USERNAME>'

 s.ios.deployment_target = '10.0'
 s.swift_version    = '4.2'

 s.source_files = 'A_Moudle/Classes/**/*'
 s.dependency 'MyRouter'
 s.dependency 'OtherMoudle'
 
 # s.resource_bundles = {
 #   'A_Moudle' => ['A_Moudle/Assets/*.png']
 # }

 # s.public_header_files = 'Pod/Classes/**/*.h'
 # s.frameworks = 'UIKit', 'MapKit'
end

B_Moudle

#
# Be sure to run `pod lib lint B_Moudle.podspec' to ensure this is a
# valid spec before submitting.
#
# Any lines starting with a # are optional, but their use is encouraged
# To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html
#

Pod::Spec.new do |s|
 s.name             = 'B_Moudle'
 s.version          = '0.3.5'
 s.summary          = 'A short description of B_Moudle.'

# This description is used to generate tags and improve search results.
#   * Think: What does it do? Why did you write it? What is the focus?
#   * Try to keep it short, snappy and to the point.
#   * Write the description between the DESC delimiters below.
#   * Finally, don't worry about the indent, CocoaPods strips it!

 s.description      = <<-DESC
TODO: Add long description of the pod here.
                      DESC

 s.homepage         = 'https://gitee.com/xxxxxx/b_moudle'
 # s.screenshots     = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2'
 s.license          = { :type => 'MIT', :file => 'LICENSE' }
 s.author           = { 'trembleCat' => 'xxxxxx@163.com' }
 s.source           = { :git => 'https://gitee.com/xxxxxx/b_moudle.git', :tag => s.version.to_s }
 # s.social_media_url = 'https://twitter.com/<TWITTER_USERNAME>'

 s.ios.deployment_target = '10.0'
 s.swift_version = '4.2'
 
 s.source_files = 'B_Moudle/Classes/**/*'
 
 s.dependency 'MyRouter'
 s.dependency 'OtherMoudle'
 
 # s.resource_bundles = {
 #   'B_Moudle' => ['B_Moudle/Assets/*.png']
 # }

 # s.public_header_files = 'Pod/Classes/**/*.h'
 # s.frameworks = 'UIKit', 'MapKit'
 # s.dependency 'AFNetworking', '~> 2.3'
end

OtherMoudle

#
# Be sure to run `pod lib lint OtherMoudle.podspec' to ensure this is a
# valid spec before submitting.
#
# Any lines starting with a # are optional, but their use is encouraged
# To learn more about a Podspec see https://guides.cocoapods.org/syntax/podspec.html
#

Pod::Spec.new do |s|
 s.name             = 'OtherMoudle'
 s.version          = '0.1.5'
 s.summary          = '对其他组件库的封装Demo'

# This description is used to generate tags and improve search results.
#   * Think: What does it do? Why did you write it? What is the focus?
#   * Try to keep it short, snappy and to the point.
#   * Write the description between the DESC delimiters below.
#   * Finally, don't worry about the indent, CocoaPods strips it!

 s.description      = <<-DESC
TODO: Add long description of the pod here.
                      DESC

 s.homepage         = 'https://gitee.com/xxxxxxx/other-moudle'
 # s.screenshots     = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2'
 s.license          = { :type => 'MIT', :file => 'LICENSE' }
 s.author           = { 'trembleCat' => 'xxxxxxx@163.com' }
 s.source           = { :git => 'https://gitee.com/xxxxxx/other-moudle.git', :tag => s.version.to_s }
 # s.social_media_url = 'https://twitter.com/<TWITTER_USERNAME>'
 s.public_header_files = "Pod/Classes/*.h"

 s.ios.deployment_target = '10.0'
 s.swift_version = '4.2'

 s.source_files = 'OtherMoudle/Classes/**/*'
 
 s.dependency 'SnapKit'
 s.dependency 'Alamofire'
 s.dependency 'Kingfisher', '~> 5.15.8'
 s.dependency 'YYText'
 
 # s.resource_bundles = {
 #   'OtherMoudle' => ['OtherMoudle/Assets/*.png']
 # }

 # s.public_header_files = 'Pod/Classes/**/*.h'
 # s.frameworks = 'UIKit', 'MapKit'
 # s.dependency 'AFNetworking', '~> 2.3'
end
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,362评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,330评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,247评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,560评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,580评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,569评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,929评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,587评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,840评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,596评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,678评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,366评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,945评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,929评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,165评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,271评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,403评论 2 342

推荐阅读更多精彩内容