组件化初探

Author Avatar
XibHe 7月 18, 2018
  • 在其它设备中阅读本文章

项目经过三年多的迭代,总共有三拨人接手,代码整体风格各异,迭代后的旧代码与新代码揉在一起,难解难分。各功能模块间相互依赖,头文件多次重复引用,编译一次大概花费5分钟。不得不进行代码组件化后重构。关于应用架构以及组件化的方案 casatwy 已经说得很透彻了,在这里我就没有必要再班门弄斧了。这篇文章主要是记录自己项目组件化时如何拆分各个功能模块?抽离组件遇到了哪些问题?阶段性测试时遇到了哪些棘手的问题?因此,这篇文章的实践性较强,希望可以解决各位在组件化过程中遇到大部分问题。

更新说明

更新记录:

  • 2018 年 07 月,第一版。
  • 2018 年 08 月,补充组件化架构图及一些说明。

现在项目中存在的问题

代码不够整齐,目录结构混乱,分类不够明确,随着一次次的版本迭代,增加了不同的业务线,开发人员变动比较频繁,工程越来越臃肿。莫名其妙的继承关系,Base类,Expand类,Category混乱,三者之间存在重复的功能,这也为后面提到的在组件化过程中遇到一个棘手问题埋下了炸弹。

期望达到的效果

  1. 解除各个功能模块间的依赖;
  2. 可以单独引入并测试某一个功能组件;
  3. 功能与功能之间不再相互引用重复的未使用到的类;
  4. 将一个单一的工程项目,分解成各个独立的组件,然后按照某种方式,任意组合成一个拥有完整业务逻辑的工程。

组件划分粒度及其相互间的依赖

组件化是为了解决项目中遇到的问题,不是为了组件化而组件化。模块的划分,以及模块的扩展性,可以增加新的模块,为将来业务的拓展留有余地。基础模块的划分很重要,其他的模块都是建立在基础模块之上的。

兵马未动,基础组件先行。

  • 基础组件(LZBasisComponents):基本配置(常量,宏),分类(各类系统类的拓展),网络(AFN的封装),工具类(loading,字符串操作,日期格式处理,文件操作等)
  • 数据库组件(LZDataBaseComponents):封装了各个功能模块的数据库增,删,改,查的基本操作,基于 FMDB 封装了基本的 SQLite 数据库操作;
  • 功能组件(LZFunctionComponents):主要用来存放一些自定义控件,eg.自定义弹出窗,错误信息提示View,自定义KeyBoardView,一些按钮和label;
  • 登录组件(LZLoginComponents):项目中通过登录操作初始化一些基本配置,配置账号体系下用户所能使用的权限。基于登录功能业务上衍生了广告页,热门活动宣传页,某个版本需要强制升级的弹出窗,以及用户协议等功能。
  • 工具箱组件(LZToolsComponents):项目中处理不同设备间数据同步的功能,页面展示的权限控制功能。

自顶向下设计,自底向上实现,先测量,后优化。— casatwy

以上所列的 6 个组件可以统称为项目基础组件,自基础向上延伸到其他功能模块,为其他模块提供底层支持,同时,在生成私有库时,其他功能模块也要与之建立起依赖关系。

这里数据的持久化基于数据库组件,其中公共类的维护 (以数据库组件为例,每个人维护自己模块下的数据库操作类。eg.LZDatabaseHelper+PatientModel 即为,患者组件下对应的所有患者的数据库操作,处理后的数据以 PatientModel 为存储形式。)

找到所有需要的模块, 把模块放在该放的地方。 — casatwy

一个公共模块,如果多个模块都会用到,那么最好是将它抽成一个组件。eg. LZToolsComponents 组件中,就存放了一些工具类,这些工具类无法下沉到基础组件中。但其它模块却会用到。

高内聚,低耦合。

接下来按功能模块划分组件就比较直观了,比较典型的就是根据 TableBar 来划分,这里适用于大部分 App 的功能模块。以下这些功能组件的划分才是组件化划分的核心所在,也只有将功能组件独立出来,才能达到高内聚,低耦合的目的。

  • 工作台组件(LZWorkTableComponents):主要处理项目中核心功能 — 开方,该模块下由三个小模块组成;
  • 药房管理组件(LZPharmacyComponents):涉及到药品库存管理的模块;
  • 患者管理组件(LZPatientManageComponent):管理患者个人就诊信息;
  • 个人中心组件(LZMineComponents):配置用户的个人信息,设置处方模板,管理诊所子账号,增值服务,账号设置,配置外接硬件(蓝牙打印机,激光打印机,扫码枪)等
  • 所有H5功能组件(LZWebViewComponents):该组件统一管理统计报表,专家咨询,知识课堂,活动商城等功能模块的 web 页面。

以上各功能组件都分别对应各自的中间件,用来同其他功能组件进行通讯。如下,

  • 登录中间件(LZLoginComponents_Category):主要用来处理登录组件(LZLoginComponents)与主工程之间的数据通讯;
  • 工作台中间件(LZWebViewComponents_category)
  • 药房管理中间件(LZPharmacyComponents_Category)
  • 患者管理中间件(LZPatientManageComponent_Category)
  • 个人中心中间件(LZMineComponents_Category)
  • H5交互中间件(LZWebViewComponents_category)

项目最终的架构图如下,

其中,webView组件处理了三个H5功能模块的页面展示及数据交互,这里把它归为业务组件的范畴。

以上是基础组件和功能组件的划分,正是有了这些泾渭分明的区分,才能为组件化之间解耦提供基础的支持。

MVC VS MVVM

对于组件化后继续使用 MVC 架构还是替换为 MVVM架构。这二者只是代码层次划分,主要是针对数据流动的方向而言。这里以解决问题为主,而不是生搬硬套架构。本片文章主要用来介绍组件化的实现流程,这里就不对架构做详细叙述了。但随着组件化的深入,这二者间终会有一个抉择,后续计划单独整理出二者之间关系的文章。

CoCoaPods 私有库

通过 cocopods 把组件打包成单独的 私有pod 库来进行管理,这样就可以通过 podfile 文件,进行动态的增删和版本管理了。

1.使用模板快速创建测试工程(组件所在的工程)

$ pod lib create LZBasisComponents

创建模板工程

按照提示,填写完以上信息后, Xcode 会自动打开创建的测试工程,在测试工程的文件夹下,可以看到的路径如下,

模板工程文件路径

其中,将抽离出的基础组件 LZBasisComponents 托至 Classes 文件夹下,此时,cd 到模板工程 Example 所在目录,执行

$ pod install

我们就可以在测试组件的模板工程中修改文件了。

2.在 GitLab 上创建一个用来存放基础组件的仓库LZBasisComponents

这里使用开源的 GitLab 作为代码托管的 git 服务器,安装完成后可以在上面新建组件。当然也可以付费使用基于 GitHub 托管,免费的有 码云码市

3.配置私有库的 .podspec 文件

在上文中生成模板工程的同时,也会生成一个 . podspec 的文件。我们需要单独设置该文件,之所以配置 .podspec 文件,是为了将本地私有库与 GitLab 上远程私有库进行关联,多人开发时通过远程私有组件库进行代码的同步。远程索引库 . podspec 即,组件描述文件,里面描述了组件文件的源码地址,框架简介,私有库作者,版本号,资源文件路径等信息。我们使用 $ pod search 检索索引文件,找到 . podspec 中的源码地址,然后将项目down到本地。其主要配置如下,

s.name         = "LZBasisComponents"
s.version      = "1.2.2.1"
s.summary      = "LZBasisComponents."

s.homepage     = "http://192.168.11.11/CloudOfficeModulization/LZBasisComponents"
s.license      = { :type => "MIT", :file => "FILE_LICENSE" }
s.author       = "xibHe"
s.platform     = :ios, "8.4"

s.source       = { :git => "http://192.168.11.11/CloudOfficeModulization/LZBasisComponents.git", :tag => s.version.to_s }
s.source_files  = "LZBasisComponents/LZBasisComponents/Classes/**/*.{h,m}"

s.resource_bundles = {
'LZBasisComponents' => ['LZBasisComponents/LZBasisComponents/Assets/*.{png,plist}']
}

s.requires_arc = true

s.dependency "Masonry", '~> 1.1.0'
s.dependency "YYKit", '~> 1.0.9'
s.dependency "ReactiveObjC"

具体的配置如下:

  • s.homepage 组件在 GitLab 的主页面
  • s.source 真实组件的地址,在 GitLab 中新建库时会生成该地址
  • s.source_files 组件中对应目录下的文件夹
  • s.resource_bundles 存放资源文件的类型
  • s.dependency 依赖其他的框架或组件

这里是组件中用到的主要的配置,还有其它的与私有 pod 相关的配置。

注意: 组件的依赖关系,各个组件中的 .podspec 文件中通过 s.dependency 设置了该组件与其他三方框架或者其他业务组件的依赖关系,这里各个组件前期可以暂时依赖其他业务组件,但后期当所有组件抽出后,需要解除与其他业务组件的依赖关系。即,将 s.dependency 下的依赖注释掉。

4.将上面创建的模板工程,提交到 GitLab 上创建的远程代码仓库中

主要是通过 git 命令上传,如下

1. cd 到 模板工程所在的 Example 对应的私有库目录

2. 可以先查看当前工程文件的状态,红色为未提交

git status

3. 提交到暂缓区

git add .

4. 将本地库与远程代码仓库进行关联

git remote add origin http://192.168.11.11/CloudOfficeModulization/LZBasisComponents.git

5. 初始化提交

git commit -m “Initial commit”

6. 将本地分支的更新,推送到远程主机

git push -u origin master

7. 给远程组件库打 tag

git tag -a 1.2.2.1 -m ‘v1.2.2.1’

git push origin master

保持 s.version = “1.2.2.1” 的版本与远程私有库的 tag 一致。

5.主工程中使用 CoCoaPods 导入私有组件库

打开并编辑主工程中的 Podfile 文件,如下,

platform :ios, '8.4'

source 'http://192.168.11.11/CloudOfficeModulization/CloudOfficeSpec.git'
source 'https://github.com/CocoaPods/Specs.git'

workspace 'CloudOffice.xcworkspace'
#use_frameworks!
inhibit_all_warnings!

def common_pods
    pod 'LZBasisComponents', :path => '/Users/huahua/Documents/Module/LZBasisComponents'

end

target  ‘CloudOffice’ do

    common_pods

end

target  ‘CloudOfficeTest’ do

    common_pods

end

target  ‘CloudOfficeTrial’ do

    common_pods

end

通过命令 pod repo 查看本地已存在的索引库,

14-cloudofficemodulization-cloudofficespec
- Type: git (master)
- URL:  http://192.168.11.11/CloudOfficeModulization/CloudOfficeSpec.git
- Path: /Users/huahua/.cocoapods/repos/14-cloudofficemodulization-cloudofficespec

master
- Type: git (master)
- URL:  https://github.com/CocoaPods/Specs.git
- Path: /Users/huahua/.cocoapods/repos/master

GitLab 中远程私有库的地址:

source 'http://192.168.11.11/CloudOfficeModulization/CloudOfficeSpec.git'

公有库的地址:

source 'https://github.com/CocoaPods/Specs.git'

cd 到主工程 Podfile 文件所在目录,执行 pod install 命令,再次打开项目就可以在 Pods 文件夹下的 Development Pods 文件夹中找到 LZBasisComponents 组件。

注意:pod ‘LZBasisComponents’, :path =>’/Users/huahua/Documents/Module/LZBasisComponents’ 对应的路径可以是存放本地私有库(已update远程私有库)的路径,也可以是 步骤4 中对应的远程私有库,可以通过通过 tag 来导入不同的私有组件库的版本,如下:

pod 'LZBasisComponents', '~> 1.2.2.1'

也可以通过 SourceTree 或者 GitHub Desktop 等拥有可视化界面的项目版本控制软件,进行 git 项目私有库的管理。

组件之间及各组件与主工程间通讯(中间件)

目前市面上有两种组件间的通讯方式:

  1. 利用 url-scheme 方案
  2. 利用 runtime 实现的 target-action 方法

两种方式都对应一些开源库,如下

URL-Scheme库:

  1. JLRoutes
  2. routable-ios
  3. HHRouter
  4. MGJRouter

Target-Action库:

  1. CTMediator

采用 url-scheme 方案,要本地调用和远程调用之间如何相互调用?提供给 url 什么样的参数?如何处理非常规对象与本地组件间的调度?

在iOS领域里,一定是组件化的中间件为openUrl提供服务,而不是openUrl方式为组件化提供服务。 — casatwy

URL注册对于实施组件化方案是完全不必要的,且通过URL注册的方式形成的组件化方案,拓展性和可维护性都会被打折。 — casatwy

采用 runtime 实现的 target-action 方法

注册 URL 的目的其实是一个服务发现的过程,在iOS领域中,服务发现的方式是不需要通过主动注册的,使用 runtime 就可以了。另外,注册部分的代码的维护是一个相对麻烦的事情,每一次支持新调用时,都要去维护一次注册列表。如果有调用被弃用了,是经常会忘记删项目的。runtime 由于不存在注册过程,那就也不会产生维护的操作,维护成本就降低了。

由于通过 runtime 做到了服务的自动发现,拓展调用接口的任务就仅在于各自的模块,任何一次新接口添加,新业务添加,都不必去主工程做操作,十分透明。— casatwy

target-action

如图,target-action 模式,即,目标-行为,行为即要调用的方法,目标即消息的接收对象(Objective-C 语言使用消息机制,类似但不同于方法调用)。整个过程为:用户点击按钮,触发某事件发生,该消息由按钮传到另外的接收对象,接收对象再做相应处理。接收对象可以为任何对象,但通常为控制器(Controller)。

target-action 的代码实现逻辑:

  1. 运行时,runtime是一套底层的C语言api,可以通过runtime获取类的私有变量;动态增加类、成员变量和方法;动态修改类、成员变量和方法;对换两个方法的实现(Swizzle
  2. NSInvocation 和 performSelector:withObject:,直接调用某个对象的消息
  3. 调用methodSignatureForSelector:方法,尝试获得一个方法签名。如果获取不到,则直接调用doesNotRecognizeSelector抛出异常。如果能获取到,则返回非nil,创建一个NSInvocation并传给forwardInvocation:
  4. 调用编译器指令,返回一个给定类型编码为一种内部表示的字符串
  5. 提供外部模块调用的方法。

组件之间的交互,通过 Action 暴露可调用接口,所有组件都通过组件自带的Target-Action 来响应,也就是说,模块与模块之间的接口被固化在了 Target-Action 这一层,避免了实施组件化的改造过程中,对业务层的侵入,同时也提高了组件化接口的可维护性。

基于以上原因,这里我推荐 Target-Action 方案。具体操作流程如下,

新增基于 CTMediator 的组件 LZMediator

基于 CTMediator 新建用于传递组件讯息的组件 LZMediator,该组件参考 CTMediator 中的 CTMediator 类,做了一些简化处理。具体创建操作参照上文中使用 CoCoaPods 建立私有库,这里就不再赘述了。

新建与其它组件通讯的中间件

这里的所说的中间件也是以 pod 私有库的形式存在于项目中,其仍是代表不同功能模块的组件。以工作台组件(LZWorkTableComponents)为例,其它组件若想调用工作台组件中的某个控制器时,不能直接调用,而是需要通过工作台中间件(LZWorkTableComponents_Category)暴露的接口调用。同上文中使用CoCoaPods 建立私有库一样,先生成模板工程,然后上传到 GitLab 远程代码厂库中。唯一需要注意的是:需要在工作台中间件的 .podspec 文件中,增加对 LZMediator 组件的依赖,如下:

s.dependency "LZMediator"

通过这种依赖关系,将 LZMediator 引入到工程中。

在中间件中新增用于通讯的 LZMediator 类别

在上面新建的工作台中间件(LZWorkTableComponents_Category)中新建基于 LZMediator 的类别,注意统一类别的名称,以工作台中间件为例,最终生成的类名为:LZMediator+LZWorkTableComponents.hLZMediator+LZWorkTableComponents.m 接下来就是按照基于 Mediator 的一套逻辑,通过实现 target-action 的方法来进行不同组件间的通讯。例如,某个组件需要调用工作台组件中的某个控制器时,

LZMediator+LZWorkTableComponents.h 中声明中间件调用的返回工作台组件中某个控制器的实例方法:

#pragma mark - 患者管理组件调用
/**
 看病开方详情页面
 @return 返回实例
 */
- (UIViewController *)LZWorkTableComponents_newDoctorPrescribingViewControllerWithDic:(NSDictionary *)params;

LZMediator+LZWorkTableComponents.m 中实现该方法:

#pragma mark - 统一前缀
NSString *const kLZMediatorTarget_LZWorkTableComponents = @"LZWorkTableComponents";

#pragma mark - 方法名称
NSString *const kLZMediatorAction_newDoctorPrescribingViewController = @"newDoctorPrescribingViewController";  // 看病开方详情页面

- (UIViewController *)LZWorkTableComponents_newDoctorPrescribingViewControllerWithDic:(NSDictionary *)params
{
    UIViewController *viewController = [self performTarget:kLZMediatorTarget_LZWorkTableComponents action:kLZMediatorAction_newDoctorPrescribingViewController params:params shouldCacheTarget:NO];
    if ([viewController isKindOfClass:[UIViewController class]]) {
        return viewController;
    }
    return [UIViewController new];
}

这里需要注意方法中传递的参数 kLZMediatorTarget_LZWorkTableComponentskLZMediatorAction_newDoctorPrescribingViewController 分别对应工作台组件中 target-action 所在的模块 (也就是提供服务的模块,这也是单独的repo,但无需被其他人依赖,其他人通过category调用这里的功能),这两个参数,前者对应的是 target-action 所在组件中的以 Target_ 为前缀的类,后者对应该类里的具体的以 Action_ 为前缀声明的某个方法。前面所说的利用 runtime 实现的 target-action 方法,指的正是这个地方。

注意: 这两个参数值,一定要与工作台组件中 Target 目录下的类名相一致!!! 切记! 切记! 切记!

在 target-action 所在的组件中增加提供服务的类

在工作台组件(LZWorkTableComponents)中 Target 目录下新建名称为: Target_LZWorkTableComponents 继承于 NSObject 的类,这个类的命名规则与上面说的 kLZMediatorTarget_LZWorkTableComponents 参数相一致,然后新增方法,方法名以前缀 Action_ 开头,同样需要与上面说的kLZMediatorAction_newDoctorPrescribingViewController 参数相一致。如下:

Target_LZWorkTableComponents.h 中声明中间件调用的返回工作台组件中某个控制器的实例方法:

#pragma mark - 患者管理组件调用
/**
 返回LZNewDoctorPrescribingViewController实例
 */
- (UIViewController *)Action_newDoctorPrescribingViewController:(NSDictionary *)param;

Target_LZWorkTableComponents.m 中实现该方法:

#import <LZDataBaseComponents/PationModel.h>
#import "LZNewDoctorPrescribingViewController.h"

/**
 返回LZNewDoctorPrescribingViewController实例

 @param param 患者dictionary
 @return LZNewDoctorPrescribingViewController实例
 */
- (UIViewController *)Action_newDoctorPrescribingViewController:(NSDictionary *)param
{
    PationModel *pationModel = [[PationModel alloc] init];
    [pationModel setValuesForKeysWithDictionary:param];

    LZNewDoctorPrescribingViewController *newDoctorPrescribinVC = [[LZNewDoctorPrescribingViewController alloc] init];
    newDoctorPrescribinVC.model = pationModel;

    return newDoctorPrescribinVC;
}

这里 PationModel 为数据库组件中的类,需要注意一下这里使用尖括号引入的头文件,LZNewDoctorPrescribingViewController 控制器为工作台组件中的类。

在其它组件中调用中间件的方法进行通讯

这里仍以工作台组件(LZWorkTableComponents)为例,比如,在患者组件中想跳转至工作台组件中的看病开方详情页(LZNewDoctorPrescribingViewController),需要通过上面的工作台中间件(LZWorkTableComponents_Category)进行通讯,而不是直接导入 #import “LZNewDoctorPrescribingViewController.h” 头文件调用。具体事例,如下:

在患者管理组件中,

#import <LZWorkTableComponents_Category/LZMediator+LZWorkTableComponents.h>

NSDictionary *pationModelDic = model.mj_keyValues;
UIViewController *newDoctorVC = [[LZMediator sharedInstance]  LZWorkTableComponents_newDoctorPrescribingViewControllerWithDic :pationModelDic];
[weakSelf presentViewController: newDoctorVC animated:YES completion:nil];

这里通过一个 LZMediator 的单例来调用工作台中间件(LZWorkTableComponents_Category)中 LZMediator 的类别(LZMediator+LZWorkTableComponents) 中的方法,获得 view controller 之后,在这种场景下,到底 push 还是 present ,其实是要由使用者决定的,mediator 只要给出 view controller 的实例就好了。

原工程中资源文件的处理

这里所说的资源文件指的是项目中的图片,现在主工程中的 Assets 存放全部的资源文件,需要将其移动到各个组件中去。在上文新建模板工程时,对应文件夹下也会生成一个 Assets 文件夹,这个文件夹就是用来存放组件中使用到的图片文件的。 针对这个文件夹中图片资源的存放和使用,有两种方式:

  1. 直接将图片 copy 到模板工程中 Assets 文件夹下,包括 @2x, @3x 图片;
  2. 在工程中组件下 Resources 目录下,新建 Assets.xcassets 文件,直接将图片拖放到 Assets 文件中,与主工程中 Assets 文件的用法一致。

针对以上两种存放图片的方式,在文件中读取图片的方式也完全不同。方式一:

s.resource_bundles = {
'LZBasisComponents' => ['LZBasisComponents/LZBasisComponents/Assets/*.{png,plist}']
}

需要在对应组件的 .podspec 文件中设置访问图片资源的路径及资源文件类型,然后在项目中导入所要访问的图片,最后访问图片,如下:

[UIImage imageWithName:@"xtsz_N" bundleName:@"LZBasisComponents"];

访问图片时需要在方法 bundleName: 后面加上图片所在的组件名称。可以写一个 UIImage 的类别统一加载组件图片资源,

@interface UIImage (Image)

//组件之间加载图片资源
+ (UIImage *)imageWithName:(NSString *)imageName forClass:(Class)sourceClass;
+ (UIImage *)imageWithName:(NSString *)imageName bundleName:(NSString *)bundleName;
+ (UIImage *)imageWithName:(NSString *)imageName bundleName:(NSString *)bundleName forClass:(Class)sourceClass;

方式二:相比方式一,更加简单,管理图片也更加直观。可以直接使用原来的方法imageWithName 来加载图片。但这里需要注意这个存放资源图片文件在组件中对应的路径并不在模板工程 Assets 路径下。

受工期影响

  1. 抽取组件前未将项目中冗余的类删除,导致这些类最后被抽到组件中;
  2. 在抽取组件时,版本仍在继续迭代,这就造成组件抽取完成后,需要再花时间合并新代码到组件中;
  3. 前期为了快速分离功能组件,往往将与该组件关联的其它功能模块的代码也抽到组件中;

出现 2 中的情况,就需要记录一下新版本迭代修改了组件中的哪些类,还要记录提测后修改 QA 提出的 bug 时修改了哪些类,最后,再对比 Gitsvn 的提交日志,查看修改了哪些类。

组件化过程中遇到的问题及解决方式

抽离业务组件,一般分为四步,这里以抽离患者管理组件为例:

  1. 将患者管理模块所有代码 copy 到新建的患者管理模板工程中(注意要将复制的代码放到 ReplaceMe.m 所在目录下);
  2. 梳理患者管理中与主工程有交互的功能类,明确使用了哪些三方库和自定义控件,剔除未使用到的类,整理工程目录结构(这一步主要是整理现有的代码逻辑,结构,去除冗余的类);
  3. 将模板工程中患者管理代码中涉及数据库操作,公用模块,全局自定义控件等其它组件的功能抽出来。那些暂时无法划分到其它组件中的类,暂时 copy 一份放到 Redundancy 文件夹下。患者管理组件只能导入其它组件并引用后,才能使用。不能再以直接引入主工程的头文件的方式调用类了(这一步主要是从组件角度斩断组件与主工程单方面的关联);
  4. 将患者管理模块所在的模板工程上传至 GitLab,然后在已集成了基础组件的壳工程中引入该组件并调试。

4 步建立在壳工程之上,这里所谓的壳工程是指集成了主工程 AppDelegate 中初始化功能和基础组件,可以进行登录操作,同步用户的数据。有了数据就可以进行页面展示,就能进一步测试组件功能是否完整。

1. duplicate symbols for architecture arm64

duplicate symbol_LZKanBingHomeViewController._closeImageV in:
    /Users/huahua/Library/Developer/Xcode/DerivedData/CloudOffice-enrpnhjeeqovpuffsdjmigxqovpi/Build/Intermediates.noindex/CloudOffice.build/Debug-iphoneos/CloudOfficeTest.build/Objects-normal/arm64/LZKanBingHomeViewController.o
    /Users/huahua/Library/Developer/Xcode/DerivedData/CloudOffice-enrpnhjeeqovpuffsdjmigxqovpi/Build/Products/Debug-iphoneos/LZWorkTableComponents/libLZWorkTableComponents.a(LZKanBingHomeViewController.o)
ld: 31 duplicate symbols for architecture arm64
clang: error: linker command failed with exit code 1 (use -v to see invocation)

根据错误提示,删除主工程中的重复类文件,因为这些类已经抽离到组件中了。这也是集成组件后联调出现最对,最常见的一种错误。

2. _OBJC_CLASS_LZKanBingHomeViewController, referenced from: objc-class-ref in libLZWorkTableComponents.a(LZWorkTableMangerController.o) ld: symbol(s) not found for architecture arm64 clang: error: linker command failed with exit code 1 (use -v to see invocation)

objc-class-ref in libLZWorkTableComponents.a(LZWorkTableMangerController.o)

ld: symbol(s) not found for architecture arm64

这个错误是由于向工程中某个组件下导入类时,未将导入类与与组件进行关联导致。根据错误提示 OBJC_CLASS_LZKanBingHomeViewController, referenced from: objc-class-ref in libLZWorkTableComponents.a 可以知道是工作台组件(LZWorkTableComponents)中的类(LZKanBingHomeViewController)未与组件建立关联。解决方式:在工程组件中选中 LZKanBingHomeViewController.m 切换到该类的 “Show the File inspector” 设置项中,检查文件的设置项。如图,

inspector

设置 “Target Membership”“LZWorkTableComponents” 为选中状态,重新编译,即可。

3. Property ‘text’ cannot be found in forward class object ‘PlaceholderTextView’Property ‘text’ cannot be found in forward class object ‘PlaceholderTextView’

头文件重复引用的问题,可以在各个基础组件中新建 .h 头文件,用于存放其他类会用到类的头文件,使用时直接导入。例如:

#import <LZBasisComponents/LZBasisComponents.h>
#import <LZDataBaseComponents/LZDataBaseComponents.h>

4. [GlobalMethod showName:@”请输入正确手机号码” inView:self.headView.dianhuaView] 不执行

这里 LZBasisComponents 中已经通过 GlobalMethod 类引入了MBProgressHUD,若在组件外重复引入新的 MBProgressHUD 会导致该代码不执行。

5. 暂时注释掉组件中跳转至其它组件控制器的方法

由于组件间不能直接进行通讯,当用于处理组件间交互的中间件没有完成时,为了编译成功,可以先注释掉跳转到其它组件控制器的代码。

6. Reference to ‘LZHttpRequestPost’ is ambiguous

+ (void)hasNewRemoteConsulationWithParam:(NSDictionary *)param
  success:(void(^)(LZHttpResponseModel *responseModel))success
  failure:(void(^)(NSError *error))failure{

[self baseRequestWithParam:param path:[LZHttpDomainManage sharedInstance].YCHZ method:LZHttpRequestPost success:success failure:failure];
}

很奇怪的一个问题,项目中顶一个一个网络请求类型的枚举 — LZHttpRequestType,项目中很多用到 LZHttpRequestPost 这个枚举类型就会报这个错误,但只要将该参数换成枚举所对应的整数值就行。暂时先这样解决,后来再看这个错误,觉得是头文件重复引用造成的,之前的老项目中有有一个叫 Foundation.h 的头文件,里面引用的是一些基本的配置类,工具类,还有一个很奇怪的引用,每个项目开发者都会在这个头文件里引入自己所编写功能的头文件。例如,

/*************此.h 文件是用来导入公用的头文件的***************/
#import "GlobalMacro.h"
#import "URLMacro.h"
#import "GlobalMethod.h"
#import "PlaceholderTextView.h"
#import "UIImageView+WebCache.h"

#import "UIView+Frame.h"
#import "MJRefresh.h"
#import "HLNavigationController.h"
#import <AdSupport/ASIdentifierManager.h>
#import "NSString+MD5.h"
#import "Masonry.h"
#import "UILabel+LZLabel.h"
#import "NSArray+LZAddition.h"

//多人开发,避免冲突
#import "LWB.h"
#import "DLN.h"
#import "LYY.h"
#import "XXL.h"

注释是为了避免多人开发的冲突,在这些每个人姓名首字母缩写的头文件中,引入的是这个人所开发功能类的头文件,就这样一层套一层。在组件中如果引用了这个Foundation.h 头文件,很大几率会造成重复重复引用。而且还有一点需要注意:组件中若要引入其它组件类的头文件,最好以尖括号引入。例如,引入基本组件中的某个类,

#import <LZBasisComponents/LZHttpDomainManage.h>

一些基本的配置类的引入,工具类的引入,则可以用包含这些类的所有头文件的一个总的 .h 头文件的形式引入,

#import <LZBasisComponents/LZBasisComponents.h>

就这样一步步的将那些多余的头文件和姓名首字母缩写的头文件从 Foundation.h 中移除,去除重复引入的头文件。

7. iOS 9.3.5 系统下,页面布局错乱的问题

组件拆分中遇到的一个很棘手的问题,在 iOS 9.3.5ipad 上,左侧切换视图的选项显示不出来,同时,右侧页面布局错乱无法点击。运行项目, 点击Debug View Hierarchy 查看视图层级,发现左侧菜单所有切换按钮的布局都乱掉了,挤在一起了。主视图控制器中的 MainViewFrame 获取的一直都是错的,工程中 Frame 坐标的获取都是通过 UIView 的类别计算获取的,如果不通过类别获取是可以得到正确的值的。但即使将主视图和菜单中所有获取坐标的方式都通过系统方法获取,还是加载不出完整的视图。

最后搜索项目中所有与计算视图布局有关的类,发现了真相。主工程中有很多计算坐标的类别,这些类别重复定义了视图的坐标,需要移除未使用的类别。同时,将组件中计算坐标的 UIView+Frame 类移至主工程中,从 podFile 中移除 HandyFrame, 移除主工程中未使用到的 UIView+AutoLayoutUIView+TXFrame 等计算页面布局的类别。

8. Include of non-modular header inside framework module ‘LZBasisComponents.LZHttpClient’: ‘/Users/zyjk_imac-penghe/Library/Developer/Xcode/DerivedData/CloudOffice-ffqipmpyrmrjqmevvpibbskoycre/Build/Products/Debug-iphoneos/AFNetworking/AFNetworking.framework/Headers/AFURLRequestSerialization.h’

Include of non-modular header inside framework module 'LZBasisComponents.AFHTTPRequestOperationManager_Synchronous': '/Users/zyjk_imac-penghe/Library/Developer/Xcode/DerivedData/CloudOffice-ffqipmpyrmrjqmevvpibbskoycre/Build/Products/Debug-iphoneos/AFNetworking/AFNetworking.framework/Headers/AFHTTPRequestOperationManager.h'

AFN中头文件引用的包含了同一个头文件,删除报错类 .h 中一些重复的引用,改为 < > 引入。

AFHTTPRequestOperationManager+Synchronous.h 中引入 #import <AFNetworking/AFHTTPRequestOperationManager.h>
AFHTTPRequestOperationManager+Synchronous.m 中引入 #import <AFNetworking/AFNetworking.h>
LZHttpClient.h 中引入 #import <AFNetworking/AFURLRequestSerialization.h>
LZHttpClient.m 中引入 #import <AFNetworking/AFNetworking.h>

9. 组件间的依赖关系

组件间的依赖关系,若两个组件间存在依赖,则依赖于某个组件的另一个组件可以直接引用所依赖组件中类的头文件,不需要加 < > 导入(但为了明确组件中引用类的来源,同时避免头文件的重复引用,建议以 < > 的方式引入头文件)。同理,若两个组件间不存在依赖关系,即使,使用了 < > 引用也无法引入该组件的任何文件。

这里需要明确项目中哪些功能模块为核心模块,哪些模块依存于核心模块。以我们项目为例,工作台组件(LZWorkTableComponents)为核心组件,在组件化初期可以以工作台组件为主,在其 .podspec 文件中依赖于其它功能组件,而不是中间件,

 s.dependency "LZLoginComponents"
 s.dependency "LZPatientManageComponent"
 s.dependency "LZPharmacyComponents"
 s.dependency "LZMineComponents"

此时需要注意:这种依赖关系是单向的。即,工作台组件依赖于其它功能组件,而其他功能组件不能依赖于工作台组件。这样也是为了避免组件间形成相互依赖的循环,为后面解除组件间依赖提供便利。

10. 如何将两个组件之间通过代理交互替换为通过中间件交互

项目中经常会用到代理处理两个不同功能间页面的跳转。在组件化过程中,也经常会遇到类似于 A组件 中的某个类需要调用 B组件 中的某个类,以完成从 A组件 push 或者 present 或者 addSubviewB组件 控制器的逻辑。

这里普通的组件间通讯可以通过上文所说的 中间件 ,复杂一些的,如将之前的代理替换为中间件需要做一些其他的处理,如下:

例如,工作台组件中 患者健康档案,需要调用患者管理组件中的 病例 模块,需要在患者管理中间件(LZPatientManageComponent_Category)中定义回调的方法,如下:

在类 LZMediator+LZPatientManageComponent 中,

#pragma mark - 统一前缀
NSString *const kLZMediatorTarget_LZPatientManageComponent = @"LZPatientManageComponent";

#pragma mark - 方法名称
NSString *const kLZMediatorAction_LuHealthRecordsView = @"LuHealthRecordsView";        // 患者健康档案view

#pragma mark - block回调
NSString * const LZPatientManageComponent_HealthRecordsViewBlock = @"HealthRecordsViewBlock";    // 患者健康档案block回调

@implementation LZMediator (LZPatientManageComponent)

/**
 创建患者健康档案实例

 @param pationModelDic 患者model字典
 @param prescriptionFlag 处方类型
 @param recoresViewBlock 患者健康档案的block回调
 @return 患者健康档案实例
 */
- (UIView *)LZPatientManageComponent_LuHealthRecordsView:(NSDictionary *)pationModelDic withPrescriptionFlag:(NSString *)prescriptionFlag withHealthRecordsViewBlock:(void (^)(NSDictionary *dic))recoresViewBlock
{
    NSMutableDictionary *params = [NSMutableDictionary dictionary];
    if (recoresViewBlock) {
        params[LZPatientManageComponent_HealthRecordsViewBlock] = recoresViewBlock;
        params[@"pationModelDic"] = pationModelDic;
        params[@"prescriptionFlag"] = prescriptionFlag;
    }

    UIView *healthRecordsView = [self performTarget:kLZMediatorTarget_LZPatientManageComponent action:kLZMediatorAction_LuHealthRecordsView params:params shouldCacheTarget:NO];
    if ([healthRecordsView isKindOfClass:[UIView class]]) {
        return healthRecordsView;
    }
    return [UIView new];
}

@end

这里主要将代理替换为 Block 以实现方法的回调。主要传递了三个参数,

  • pationModelDic,由于病例视图展示的数据是以 model 形式传递的,而中间件只能接收字典类型的参数,所以在调用方法前需要将 model 转化为 NSDictionary
  • prescriptionFlag,该参数为临时添加的参数,用于判断处方类型。
  • recoresViewBlock,患者健康档案 block 回调,针对视图 show 之后的各种附加处理,如,网络请求,存储返回值。

这里需要在最终需要调用的患者病历视图类 (LuHealthRecordsView) 中增加对应的 Block 方法,当操作 LuHealthRecordsView 的实例触发视图上的事件时会有结果值回调。(这一步正是替代了原有的两个功能组件间用于交互的代理方法)。具体如下:

LuHealthRecordsView.h 中声明,

typedef void (^HealthRecordBlock)(NSDictionary *dic);   // 用于返回病史/过敏史的block回调(主要应用于患者中间件的传值)

@property (nonatomic, copy) HealthRecordBlock healthRecordBlock;

LuHealthRecordsView.m 中触发回调,

- (void)sureBtnClick
{
    NSDictionary *recordsDic = @{@"buttonType": @"1",
                                 @"userTag": [NSNumber numberWithInteger:self.userTag],
                                 @"illHistory": self.contentView2.view1.textView.text,
                                 @"allergicHistory": self.contentView2.view2.textView.text
                                 };
    self.healthRecordBlock(recordsDic);
}

这里同样是以字典方式返回操作结果,这也是组件化中很重要的一个点 —- 去 model 化

在这里还要提一个点,这个大的 Action 方法的调用,传入的所有参数都需要与实际要调用的视图一一对应。也就是说用到什么参数,就在该方法中增加什么参数。

患者管理组件(LZPatientManageComponent)中,Target 目录下处理调用类实例方法,在类 Target_LZPatientManageComponent 对应方法下实现,如下:

Target_LZPatientManageComponent.m

/**
 返回LuHealthRecordsView视图
 */
- (UIView *)Action_LuHealthRecordsView:(NSDictionary *)param
{
    LuHealthRecordsView *healthRecordsView = [[LuHealthRecordsView alloc] initWithFrame:CGRectMake(106/2, 70, SCREEN_WIDTH - 106, SCREEN_HEIGHT - 140)];
    NSDictionary *pationModelDic = param[@"pationModelDic"];
    PationModel *pationModel = [[PationModel alloc] init];
    [pationModel setValuesForKeysWithDictionary:pationModelDic];
    healthRecordsView.model = pationModel;
    healthRecordsView.prescriptionFlag = param[@"prescriptionFlag"];
    [healthRecordsView showMe];

    healthRecordsView.healthRecordBlock = param[@"HealthRecordsViewBlock"];

    return healthRecordsView;
}

LuHealthRecordsView 为最终需要调用的视图类,在调用前需要将传过来的参数进行转化,

  • param[@”pationModelDic”] 转化为对应的 PationModel
  • param[@”prescriptionFlag”] 处方类型的标识
  • 还有最为重要的一点,将 param[@”HealthRecordsViewBlock”]block 赋给 healthRecordsView.healthRecordBlock 从而触发回调。

最后,在工作台组件中调用患者管理中间件进行通讯,如下:

NSDictionary *pationModelDic = self.patientModel.mj_keyValues;
    __weak typeof(self) weakSelf = self;
    UIView *healthRecordsView = [[LZMediator sharedInstance] LZPatientManageComponent_LuHealthRecordsView:pationModelDic withPrescriptionFlag:@"3" withHealthRecordsViewBlock:^(NSDictionary *dic) {

        NSString *buttonType = dic[@"buttonType"];
        weakSelf.illHistory = dic[@"illHistory"];
        weakSelf.allergicHistory = dic[@"allergicHistory"];

        if ([buttonType isEqualToString:@"0"]) {
            //键盘处理
//            [IQKeyboardManager sharedManager].enable = YES;
            [self.healthRecordsBgView removeFromSuperview];
            NSInteger userTag = [dic[@"userTag"] integerValue];
            if (userTag != 3) {
                [weakSelf savePatientHealthRecords];
            }

        } else if ([buttonType isEqualToString:@"1"]) {
            //键盘处理
//            [IQKeyboardManager sharedManager].enable = YES;
            [weakSelf savePatientHealthRecords];
            weakSelf.headerView.patientModel = weakSelf.patientModel;

        }
    }];
    [_healthbg addSubview:healthRecordsView];

block 回调中处理了未进行组件化前代理方法的工作。最后,这里需要强调一个点,当用于通讯的中间件中需要新增与某个组件通讯的方法时,最好是遵循一个原则:

谁污染谁治理,谁调用谁新增。

即,哪个组件需要进行与其它组件通讯,则由有这个需要的组件的发起者,去他需要调用的组件的中间件中新增用于通讯的方法。

写在最后

涉及到项目组件化还有一些收尾工作:

  • 创建壳工程,配置App运行的基本环境
  • 去除主工程 Assets.xcassets 目录下的多余资源文件(这些文件已抽到各个业务功能组件中)
  • 组件化方案中的去 model 的设计
  • 将各个功能模块中涉及到数据库组件,SQLite 的查询语句都移动到数据库组件中

5月初开始对项目进行重构,原以为只要项目完成组件化,重构工作就顺理成章的结束了。但现在看来组件化不是重构的结束,仅仅是一个开始。在我看来以私有 pod 为形式的组件化,只是一种强制解除各个功能模块之间耦合度的方式。它将一个复杂的项目按功能拆分成不同 pod 库,不同组件之前想要通讯,只能通过中间件。因此,我认为组件化的本质是对代码结构的整理,它无形中制定了一套编码规范,迫使开发者在编写代码时不能随心所欲的按照自己的喜好堆放代码。

如果你一开始都注意代码规范,同时又时时留意该如果降低工程中功能间的耦合度。那么,完全没有必要进行组件化。

组件化只是术,而非道。

组件化只是项目重构的第一步,项目重构之路道阻且长,但行则必至。这里为了在后续的重构工作中提醒自己,同时,打个疫苗(希望这次打的是真疫苗)预防一下后续重构中可能会遇到的问题。翻译了一篇文章 — 重写代码会失败的几个征兆 对比文章中的5个方面,结合正在开发的项目,希望有所帮助。

参考资料

iOS应用架构谈 开篇 - Casa Taloyum

iOS应用架构谈 view层的组织和调用方案 - Casa Taloyum

iOS应用架构谈 网络层设计方案 - Casa Taloyum

iOS应用架构谈 本地持久化方案及动态部署 - Casa Taloyum

iOS应用架构谈 组件化方案 - Casa Taloyum

CTMediator

组件化方案调研

模块化与解耦

服务器上的 Git - GitLab

XCode 7.1 - Include of non-modular header inside framework

Include of non-modular header inside framework module

Reference to ‘enum_value’ is ambiguous

Make AFNetworking compatible for an iOS 8 Cocoa Touch Framework

–EOF–

若无特别说明,本站文章均为原创,转载请保留链接,谢谢