暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

iOS模块化方案及实施

AggrxTech 2021-10-25
808

1. What:什么是模块化

首先来看一下百度百科对模块化的定义:模块化是指解决一个复杂问题时自顶向下逐层把系统划分成若干模块的过程,有多种属性,分别反映其内部特性。现代软件开发往往以模块作为基本的合成单位,模块间通过接口进行功能调用,这种方式使得每个模块聚焦于自身的业务,无论是对于代码复用或者是程序的稳定性都有着积极的作用。可以这么说:没有模块化编程作为软件开发的基础,就没有如今的信息时代。

2. Why:为什么要做模块化

对前端开发来说,大概只有教学Demo中才会从头到尾使用自己编写的代码,应用商店中的App至少会集成稳定性监控、数据上报、网络访问、DB存储服务等基本的服务模块,对于中大型的App可能集成数十个三方框架。所以即便是没有自己动手做过,也肯定使用过别人的模块。模块化的原因和优点,网上已经有很多阐述了,这里说一下我们做模块化的原因:

  • 方便多个工程中共享,便于迭代后在项目间同步。

  • 减少building时间,从而提高开发效率。

  • 模块间只允许单项依赖,代码责任清晰。

  • 限制代码修改影响范围,提高问题定位和修复效率。

3. How:如何做到模块化

XCode提供了对iOS模块化开发的支持,我们可以通过subproject的方式,很方便的把需要模块化的代码拆分出来。对于多个平级的工程,则需要使用workspace管理多个工程。

具体怎么做?

3.1 依赖管理工具

iOS平台的依赖管理工具有三个:CocoaPods、Carthage、Swift Package Manager(SPM)。

CocoaPods主要特点是有一个中心化的仓库负责管理全部代码库,这样做的好处显而易见:可以方便的搜索到需要的代码库,更新时也只需要和仓库版本对比就可以了;几乎所有的库都支持CocoaPods。缺点的话主要有:首次使用时需要下载一个庞大的Spec库;另外就是每次构建都会同时编译依赖导致build时间较长,好在有Xcode的编译缓存,只有首次构建和clean之后才会有这个问题。

Carthage是去中心化的,是Swift编写的开源工具。去中心化的结果就是:省掉了中心库的维护以及中心库连接失败的风险,同时也增加了检索需要的库的难度。另一个问题就是不是所有的库都支持Carthage。

Swift Package Manager是苹果自家的依赖管理工具,在Xcode中已经集成好了。最初只支持macOS和Linux两个平台,从Swift 5 + Xcode11开始增加了iOS/tvOS的支持。

3.2 实现自己的模块

比较之后我选择CocoaPods作为模块管理工具,安装的教程可以自行搜索。

首先使用pod lib create来建立自己的spec,在回答几个基本的问题之后,CocoaPods会从github上clone一个spec的工程模版,并按照我们的需求把这个模版修改为适当的Framework工程。


# pod lib create NTYBase
Cloning `https://github.com/CocoaPods/pod-template.git` into `NTYBase`.
Configuring NTYBase template.
security: SecKeychainSearchCopyNext: The specified item could not be found in the keychain.
------------------------------
To get you started we need to ask a few questions, this should only take a minute.
If this is your first time we recommend running through with the guide:
 - https://guides.cocoapods.org/making/using-pod-lib-create.html
 ( hold cmd and click links to open in a browser. )
What platform do you want to use?? [ iOS / macOS ]
 >
ios
What language do you want to use?? [ Swift / ObjC ]
 > objc
Would you like to include a demo application with your library? [ Yes / No ]
 > yes
Which testing frameworks will you use? [ Specta / Kiwi / None ]
 > none
Would you like to do view based testing? [ Yes / No ]
 > no
What is your class prefix?
 > NTY
...
Running pod install on your new library.
Analyzing dependencies
Downloading dependencies
Installing NTYBase (0.1.0)
Generating Pods project
Integrating client project
[!] Please close any current Xcode sessions and use `NTYBase.xcworkspace` for this project from now on.
Pod installation complete! There is 1 dependency from the Podfile and 1 total pod installed.
[!] Your project does not explicitly specify the CocoaPods master specs repo. Since CDN is now used as the default, you may safely remove it from your repos directory via `pod repo remove master`. To suppress this warning please add `warn_for_unused_master_specs_repo => false` to your Podfile.
 Ace! you're ready to go!
 We will start you off by opening your project in Xcode
  open 'NTYBase/Example/NTYBase.xcworkspace'
To learn more about the template see `https://github.com/CocoaPods/pod-template.git`.
To learn more about creating a new pod, see `https://guides.cocoapods.org/making/making-a-cocoapod`.

工程结构如图:

其中Podspec Metadata下的NTYBase.podspec就是框架的spec文件。NTYBase下的ReplaceMe.m可以替换为框架的代码,这样我们自己的模块就完成了。

3.3 代码分离

考虑到基础功能复用性高,迭代更新频率较低。对于大部分项目来说,模块可以采取先自下而上纵向拆分的方法,首先分离基础模块;然后再按照业务拆分业务部分。

在笔者的项目中首先抽取了基础功能部分,主要包括线程调度、JSON文件解析、ObjC对象安全及类型转换、数组/字典/字符串便利方法、运行时软硬件环境数据获取、常用宏、错误处理、KVO安全支持等。之后抽取了日志部分,这两部分基本上是App的基础,并且基本上不依赖其他部分。

接下来抽取了网络模块,因为遇到的问题比较典型所以做一下展开说明。


// Request.m 网络请求
// 1. 主机名
- (NSString*)host {
    return APIHost; 
}
// 2. 请求参数
+ (NSDictionary *)commonParameters {
    return [[self staticParameters] nty_joined:@{
        @"gender2":NONNULL_STR(DN_UserGender2),
        @"age2":NONNULL_STR(DN_UserAge2),
        @"uid": NONNULL_STR(DN_UserID),
        @"tk":NONNULL_STR(DN_UserToken)
    }];
}

通过上述代码片段可以看出几个问题:网络模块耦合了用户中心、App服务等模块;APIHost在不同的App中不一定会提供相同的宏实现;DN开头的宏是项目相关代码需要去除。

这里可以通过依赖注入来解决问题:增加NTYRequestContext,网络模块使用上下文屏蔽外部依赖,项目把初始化好的上下文配置好并提供给网络模块即可。


// 网络上下文
@interface NTYRequestContext : NSObject
@property (nonatomic, copy) NSString *APIHost;
@property (nonatomic, copy) NSString *UserAgent;
@property (nonatomic, copy) NSDictionary *staticHeaders;
@property (nonatomic, copy) NSDictionary *staticParameters;
- (NSDictionary *)commonHeaders;
- (NSDictionary *)commonParameters;
// ...
@end
// 网络初始化
NTYRequestContext *context = [NTYRequestContext defaultContext];
context.apiHost = APIHost;
context.userAgent = [NTYNetworking userAgent];
context.staticHeaders = [NTYNetworking staticHeaders];
context.staticParameters = [NTYNetworking staticParameters];
context.commonHeadersBlock = ^NSDictionary * _Nonnull{
  return @{
      @"session_eid": [GSReportCenter getSessionEid],
  };
};
context.commonParametersBlock = ^NSDictionary * _Nonnull{
    return @{
        @"gender":NONNULL_STR(DN_UserGender2),
        @"age":NONNULL_STR(DN_UserAge2),
        @"uid": NONNULL_STR(DN_UserID),
        @"tk":NONNULL_STR(DN_UserToken)
    };
};
// Request.m
- (NSString*)host {
    return requestContext.APIHost;
}
+ (NSDictionary *)commonParameters {
    return requestContext.commonParameters;
}

模块间的耦合、不同项目间共用模块的功能差异,这些都是模块抽取时需要认真对待的问题。因为模块拆分属于代码重构,所以不建议大规模重构之后提交测试并发版,更好的做法是逐个拆分后随需求提测。在解决好耦合问题之后,模块拆分就完成了。

3.4 使用模块


Pod::Spec.new do |s|
  s.name             = '******' // spec名
  s.version          = '0.1.0'  // 版本
  s.summary          = 'NTYBase Lib.' // 简介
# 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      = 'NTYBase Lib. A best lib.' // 描述
  s.homepage         = 'https://github.com/author/******' // spec主页
  # s.screenshots     = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2' // 截屏
  s.license          = { :type => 'MIT', :file => 'LICENSE' } // 许可
  s.author           = { 'author' => 'author@email.com' } // 作者
  s.source           = { :git => 'https://github.com/author/******.git', :tag => s.version.to_s } // git仓库
  # s.social_media_url = 'https://twitter.com/<TWITTER_USERNAME>'
  s.ios.deployment_target = '9.0' // ios部署版本
  s.source_files = 'NTYBase/Classes/**/*' // 源码
  
  # s.resource_bundles = {  // 资源
  #   'NTYBase' => ['NTYBase/Assets/*.png']
  # }
  # s.public_header_files = 'Pod/Classes/**/*.h'  // public 头文件
  # s.frameworks = 'UIKit', 'MapKit'  // 系统库
  # s.dependency 'AFNetworking', '~> 2.3' // 依赖
end

对于内部模块来说spec不需要发布到CocoaPods的中心仓库,所以不需要通过pod spec lint检查,这里只需要用pod lib lint检查即可,两者的区别就是后者仅做本地验证。


# pod lib lint
 -> NTYBase (0.1.0)
    - NOTE  | url: The URL (https://github.com/author/******) is not reachable.
    - NOTE  | xcodebuild:  note: Using new build system
    - NOTE  | xcodebuild:  note: Building targets in parallel
    - NOTE  | xcodebuild:  note: Using codesigning identity override: -
    - NOTE  | xcodebuild:  note: Build preparation complete
    - NOTE  | [iOS] xcodebuild:  note: Planning build
    - NOTE  | [iOS] xcodebuild:  note: Analyzing workspace
    - NOTE  | [iOS] xcodebuild:  note: Constructing build description
NTYBase passed validation.

在通过验证之后,就可以像普通spec一样在项目的Podfile中使用了,因为模块spec没有上传到中心仓库,所以需要用git指定代码仓库。这里比较常见的问题是在push本地代码以后,需要按照spec中的version为git仓库添加一个tag,缺少tag的话模块会安装失败。

在项目中使用pod init创建的Podfile默认都用use_frameworks!开启了动态库,在install完成后就可以正常使用了。由于动态库加载对App启动速度影响较大,官方的建议是不超过6个,所以这里推荐使用静态库。(CocoaPods在1.5.0版以后也支持Swift静态库)

使用静态库时,可能会遇到import找不到头文件的问题,实际上仅仅是代码提示没有,手动输入以后是可以build的。解决方法网上说法有两种:一、Build Phases中Headers没有加到Public;二、需要在Build Setting的User Header Search Paths增加${PODS_ROOT},并设置为recursive。笔者都尝试过并不能解决问题,实际上需要在Build Phases中增加Copy Files并且把需要的Header文件添加进去,这样就可以在工程中import静态库了。


使用静态库中如果含有Category,还会出现selector找不到的问题,原因在于 Category 是 Objective-C 语言的特性,编译器并不会为它生成链接符号,链接器将会直接忽略掉 Category 对应的对象文件。这里推荐一种解决方法就是在Category文件里使用下面的宏添加Fake符号,以确保Category正常链接。


#define FIX_CATEGORY_BUG_H(name) \
@interface FIX_CATEGORY_BUG_##name : NSObject \
+(void)print; \
@end
#define FIX_CATEGORY_BUG_M(name) \
@implementation FIX_CATEGORY_BUG_##name \
+ (void)print {} \
@end
#define ENABLE_CATEGORY(name) [FIX_CATEGORY_BUG_##name print]

 

3.5 对外发布

对于具备一定业务能力的模块,按照业务需要可以转化为具备服务能力的SDK,大体上需要做的事情和普通模块类似。推荐使用coocapods-packager插件:


pod package NTYBase.podspec --embedded --force --no-mangle --spec-sources=https://github.com/author/******.git,https://github.com/CocoaPods/Specs.git
# 选项说明
--force
# 强制覆盖之前已经生成过的二进制库
--embedded
# 生成静态.framework
--library
# 生成静态.a
--dynamic
# 生成动态.framework
--bundle-identifier
# 动态.framework是需要签名的,所以只有生成动态库的时候需要这个BundleId
--exclude-deps
# 不包含依赖的pod库的符号表/依赖的pod库不打包进去。生成动态库的时候不能使用这个命令,动态库一定需要包含依赖的符号表。
--configuration
# 表示生成的库是debug还是release,默认是release 。
例如:--configuration=Debug,ONLY_ACTIVE_ARCH=NO
--no-mangle
# 表示 Do not mangle symbols of depedendant Pods,当你的项目依赖包含静态库时,
不加上这句,就会打包失败:
[!] podspec has binary-only depedencies,mangling not possible.
--subspecs
# 如果你的pod库有subspec,那么加上这个命名表示只给某个或几个subspec生成二进制库,
# --subspecs=subspec1,subspec2。生成的库的名字就是你podspec的名字,
# 如果你想生成的库的名字跟subspec的名字一样,那么就需要修改podspec的名字。
# 这个脚本就是批量生成subspec的二进制库,每一个subspec的库名就是podspecName+subspecName。
--spec-sources
# 一些依赖的source,如果你有依赖是来自于私有库的,
# 那就需要加上那个私有库的source,默认是cocoapods的Specs仓库。
# --spec-sources=private,https://github.com/CocoaPods/Specs.git。

打包完成会生成NTYBase-0.1.0文件夹,不推荐使用--library生成.a静态库,还需要把头文件整理到一起才能对外提供。

4. 总结

模块化对于开发效率提升、改善工程稳定性都具有重要作用。模块拆分在作者看来是维持单位代码规模的手段,尽管在工程中通过把不同业务功能划成不同的组,分配给不同的开发者实现,但缺少规范的限制,由于各种原因会导致模块间耦合日益加深,久而久之无论是Bug修复还是功能升级的开发和测试成本都会增加。

模块化的另一个重要目的就是代码复用,很多基础功能实际上都能从成熟的项目中复用到新项目当中,按照需要copy&paste显然效果有限,这些功能如果出现Bug修复、实现优化就很难同步到所有项目当中。

上述问题都是模块拆分交给专业的工具的原因。本文是模块化实践的一些基础内容,还有很多细节欢迎留言讨论。


文章转载自AggrxTech,如果涉嫌侵权,请发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论