一、组件化介绍
这篇文章已经写了好久了,大概有两年了,其实早就写完了,没有发。趁着有时间把这篇文章完善完善发出来。言归正传,组件化也叫模块化,是指将应用程序分解为独立、可重用、可交互模块的开放策略,这种方式有助于提高代码复用率、降低耦合度,使得应用程序更易于维护和扩展。
1.1 组件化的好处
那使用组件化具体都有哪些好处,有以下几点:
- 模块间解藕
- 模块重用
- 提高团队协作开发效率
- 单元测试
1.2 组件化的要求
那什么情况下不需要组件化呢,有以下几点:
- 项目较小,模块间交互简单,耦合少
- 模块没有被多个外部模块引用,只是一个单独的小模块
- 模块不需要重用,代码也很少被修改
- 团队规模很小
- 就是如果项目比较小,就不需要组件化了,因为使用组件化的过程中,需要写比较多的代码,使用的时候稍微麻烦些。
- 如果项目比较大,代码量多,业务逻辑比较复杂,多个人维护;或者公司有很多类似的项目,例如马甲,这个时候使用组件化是一个不错的选择。
1.3 组件化分层
在使用组件化的时候,我们要必须保证封装的颗粒度够,那模块间的分层大致如下图所示:
大致可以分为三层:
- 第一层:第一层是基础模块,主要包含底层组件,宏定义,分类等。
- 第二层:第二层是通用模块,主要包含常用控件,数据管理,分享、第三方登录等。
- 第三层:第三层是业务模块,主要包含我们日常接触最多的业务,例如项目的首页,我的等模块。
这些模块,我们都可以使用 CocoaPods 工具来进行管理。当然,使用组件化再给模块分层的时候,也要注意以下几点:
- 模块的构建至下而上,基础模->通用模->业务模块。
- 横向不能相互依赖,只能上层对下层依赖。
- 项目公共代码资源下沉,横向的依赖最好下沉。
- 每个模块都可以单独运行。
1.4 CocoaPods 工作流程
既然组件化可以使用 CocoaPods 工具来进行管理,那 CocoaPods 是怎么工作的呢,它的一个工作流程可以用一张图来表示:
- 首先将本地代码仓库打上标签,推送到远程代码仓库。
- 将本地代码仓库的 .podspec 文件的信息推送到远程公共仓库 Specs(这是一个索引库)。
- 在工程中集成远程框架时,将远程公共索引库下载到本地。
- 通过下载到本地的公共索引库搜索到需要集成框架的远程代码仓库地址。
- CocoaPods 通过远程代码仓库地址将远程仓库代码集成到工程当中。
以上就是 CocoaPods 大致的工作流程。
二、创建本地组件库
2.1 创建组件库
打开终端,在指定目录下执行:pod lib create <Module名称>
即可在本地创建组件库。创建的过程中会提示选择平台和语言等,如图所示:
创建完成之后会自动打开组件库的测试用例工程,其目录结构如图所示:
接下来我们将写好的源代码文件放到 <Module>/<Module>/<Classes>
目录下,如图所示:
Assets 文件夹在第六大点<组件库加载资源文件>会有详细说明。将写好的源代码文件放到该目录下之后,在终端 cd 到 Example 目录下运行 pod install
,就可以在测试用例工程中使用了。如图所示:
通过 pod lib create
创建本地组件库会帮生成很多的文件,其中,README.md 是介绍这个组件的用途和使用,LICENSE 文件是一个协议文件,还有一个 .podspec 文件,接下来主要介绍 .podspec 文件。
2.2 podspec 文件介绍
.podspec 文件主要是用于配置组件库的一些信息,例如名称、版本、下载地址等信息,其部分语法说明如下:
swift
Pod::Spec.new do |s|
# 库的名称
s.name = 'MKCommonModule'
# 版本号
s.version = '0.1.0'
# 库的简介
s.summary = 'A short description of MKCommonModule.'
# 库的描述
s.description = <<-DESC
TODO: Add long description of the pod here.
DESC
# 主页,可以是库的地址,或是社交地址、博客等,随便写
s.homepage = 'https://github.com/Fat brother/MKCommonModule'
# s.screenshots = 'www.example.com/screenshots_1', 'www.example.com/screenshots_2'
# 开源协议
s.license = { :type => 'MIT', :file => 'LICENSE' }
# 作者、邮箱号
s.author = { 'Fat brother' => 'maker@126.com' }
# 下载地址,这个不要乱写,一定要和远程仓库的 git 地址一致
s.source = { :git => 'https://github.com/Fat brother/MKCommonModule.git', :tag => s.version.to_s }
# 社交网址
# s.social_media_url = 'https://twitter.com/<TWITTER_USERNAME>'
# 最低版本支持
s.ios.deployment_target = '12.0'
# 使用 swift 的版本
s.swift_version = '5.0'
# 源文件位置
s.source_files = 'MKCommonModule/Classes/**/*'
# 资源文件位置
# s.resource_bundles = {
# 'MKCommonModule' => ['MKCommonModule/Assets/*.png']
# }
# 公共头文件
# s.public_header_files = 'Pod/Classes/**/*.h'
# 依赖的系统库
# s.frameworks = 'UIKit', 'MapKit'
# 依赖的第三方库
# s.dependency 'AFNetworking', '~> 2.3'
end
官方 podspec 语法参考地址:guides.cocoapods.org/syntax/pods...
三、本地组件库上传到 Gitee
上传到 GitHub 实在是太慢了,所以使用 Gitee 进行演示。下面介绍如何上传到 Gitee 的远程仓库。
3.1 在 Gitee 创建远程仓库
在创建远程仓库的时候,有以下几点需要注意的:
- 仓库的名称要和本地组件库的名称一致;
- 因为使用
pod lib create
创建本地组件库的时候已经帮我们生成了很多东西,所以我们只要创建一个空的仓库就好了; pod lib create
创建本地组件库创建本地组件库的时候,其默认的 git 分支是 main,所以远程仓库的默认分支也要是 main。
如图所示:
3.2 本地组件库关联远程仓库
修改 .podspec 文件中的 s.source
,这里面描述仓库的原地址一定要和远程仓库的 git 地址一致!
cd 到本地组件库的目录下,分别执行以下命令:
csharp
// 关联远程仓库
git remote add origin <远程仓库地址>
// 将本地仓库推送到远程仓库的分支
git push -u origin "分支"
执行之后就关联成功了,如图所示:
刷新远程仓库,就可以看到刚刚提交的代码了。
四、将组件库发布到 CocoaPods
如何在项目工程中像通过 CocoaPods 管理第三方库一样来管理自己的组件库呢,就是我们需要将组件库的 .podspec 文件的信息上传到远程 CocoaPods 的 Specs 库。
远程 Specs 库是 CocoaPods 用来存储 xxx.podspec.json 文件的仓库,这个仓库你可以理解为它就是一个索引库,里面存放的都是开源库的索引信息。
Specs 仓库地址:github.com/CocoaPods/S...
4.1 CocoaPods Trunk
在将组件库的 .podspec 文件信息上传到远程 Specs 库之前,有必要了解一个东西:CocoaPods Trunk。
CocoaPods Trunk 是一种身份验证和 CocoaPods API 服务。要将新的或更新的库发布到 CocoaPods 以供公开发布,需要在 Trunk 注册,并在当前设备上进行有效的 Trunk 会话。
使用 pod trunk me
查看你是否已经注册了 CocoaPods Trunk,如果没有注册,在终端运行:
ini
pod trunk register 邮箱地址 '用户名' --description='Mac设备的描述'
邮箱一般使用注册了 GitHub 的邮箱和用户名,也可以用其他的邮箱。执行该命令后 CocoaPods 会发一封邮件到你的邮箱,点击邮件中的链接就验证并注册成功。
在终端输运行 pod trunk me
列出注册成功的会话。
注册成功之后,如图所示:
Trunk 帐户没有密码,只有每台计算机会话令牌。
4.2 验证本地组件库的 .podspec 文件的语法
在当前设备成功注册 CocoaPods Trunk 之后,接下来验证本地 .podspec 文件的语法是否正确。
这一步需要注意几个点,
- 保证本地的组件库能够正常运行。
- 在修改 .podspec 文件后,一定要在测试用例工程目录下,在终端运行
pod install
来保证导入本地组件库是成功的。
cd 到组件库目录下,在终端运行:
css
pod lib lint --allow-warnings
如图所示:
4.3 代码提交并推送至远程仓库
在确保语法没有问题之后,就可以将改动的代码提交至远程仓库(如果已经提交,直接跳过这一步)。
在终端分别运行:
csharp
// 提交
git add -A && git commit -m "Release 0.1.0"
// 推送
git push -u origin "main"
4.4 打一个标签并推送到远程仓库
在终端分别运行:
arduino
// 打标签
git tag '0.1.0'
// 推送标签
git push ----tags
注意:打标签的时候,你的标签号一定要和 .podspec 文件中描述的版本号保持一致!
4.5 发布到 CocoaPods
4.5.1 发布至远程 Specs 库
在终端运行:
css
pod trunk push NAME.podspec --allow-warnings
这个命令其实就是将 .podspec 文件的信息上传到远程索引库(Specs 库)中。
将 .podspec 文件的信息上传到远程索引库(Specs 库)之后,如果你的组件库是一个开源库,在 Profile 中导入的方式为:
pod 'xxxModule', ' ~> 1.0.0'
如果你的组件库是一个私有库,在 Profile 中导入的方式为:
javascript
pod 'xxxModule', :tag => ' ~> 1.0.0', :git => '仓库地址(https://github.com/xxx/xxxModule.git)'
发布成功之后,新建一个工程,测试是否能够将刚上传的组件库导进项目中,如图所示:
测试成功!
4.5.2 发布到远程私有 Specs 库
如果不想让自己的组件库开源出去,上传到公共的索引库的话,我们也可以搭建自己的私有索引库。如何搭建私有索引库,并将 .podspec 文件上传到私有索引库可以参考 CocoaPods 官方文档的操作;也可以参考下面的操作。
在 Gitee 中新建一个仓库,勾选一下模版文件:Readme 文件。如图所示:
MySpecs 仓库就是我们创建的私有索引库。
在终端运行:
xml
pod repo add <远程私有索引库名称> <远程私有索引库git地址>
如图所示:
运行成功后,在终端运行 open ~/.cocoapods/repos
打开 repos 文件夹,发现我们创建的索引库已经在这个目录下了,如图所示:
接下来 cd 到本地组件库目录下,在终端运行:
css
pod repo push REPO NAME.podspec --allow-warnings
REPO 为私有索引库名称,在终端运行的效果如图所示:
运行成功后,到远程索引库中刷新页面就可以看到刚刚上传的 .podspec 文件了,如图所示:
在终端运行 open ~/.cocoapods/repos
打开 repos 文件夹,也可以看到在本地的私有索引库也有了记录,如图所示:
项目工程中如何通过私有索引库找到组件库的地址并导进项目中呢,在 Podfile 中的配置如下:
swift
# 指明依赖库的来源地址
source 'https://gitee.com/maker-ios-modules/my-specs.git'
platform :ios, '12.0'
# 忽略引入库的所有警告(强迫症者的福音啊)
inhibit_all_warnings!
target 'cocoaPods测试' do
# Comment the next line if you don't want to use dynamic frameworks
use_frameworks!
pod 'MKCommonModule'
# Pods for cocoaPods测试
end
只需要通过 source 指定组件库的地址是来源于私有索引库的就可以了,在终端运行 pod install
效果如图所示:
成功导入!
4.6 添加仓库的维护人员
有些组件库可能不止你一个人维护,也需要其他小伙伴维护的时候,可以在终端运行:
xml
pod trunk add-owner <组件库名称> <小伙伴的邮箱地址>
只有第一个将 .podspec 推送到 Trunk 的人可以添加其他维护人员。例如,要将 kyle@cocoapods.org 添加到 ARAnalytics 库中:
sql
pod trunk add-owner ARAnalytics kyle@cocoapods.org
注意:kyle@cocoapods.org 需要已经在 Trunk 上注册了一个帐户,以便你将他们添加到库中。
五、组件库的依赖
5.1 依赖第三方库
我们重新创建一个通用的组件库 MKCommonUIModule,接下来把通用UI组件的代码放到该组件的 Classes 目录下,如图所示:
接下来我们回到 MKCommonUIModule 的 Example 目录下,重新 pod install
,然后打开项目,编译项目会发现报了很多错误,如图所示:
一个是找不到宏定义相关的常量,一个是找不到 Masonry 这个库的相关定义;我们首先先解决 Masonry 相关的。
那么当组件库需要依赖于第三方库的时候,我们应该怎么处理呢?在项目中,找到 Pods->Development Pods->Pod 目录下,有一个 .podspec 文件,如图所示:
那怎么依赖一个第三方库呢,其实 .podspec 文件的最后一行已经给我们一个例子了,把 AFNetworking 和 Masonry 依赖到 MKCommonUIModule 中,如图所示:
接着重新 pod install
,然后到项目中,重新编译,我们会发现,项目中的报错一下子少了很多,只剩下宏定义相关的常量了,如图所示:
此时的 Pods 也多了 AFNetworking 和 Masonry 库,如图所示:
接下来只需要把宏定义相关的常量的库(也就是自己的基础库)也给依赖进来。
5.2 依赖本地组件库
同样的我们创建一个组件库 MKMacroAndCategoryModule,然后把写好的代码放到该组件的 Classes 目录下,如图所示:
在该组件的 Example 目录下重新 pod install
并且编译通过之后,将 MKMacroAndCategoryModule 依赖到 MKCommonUIModule 中,如图所示:
接着在 MKCommonUIModule 的 Example 目录下重新 pod install
,然后就报了一个错误:
这个提示是找不到 MKCommonUIModule 所依赖的 MKMacroAndCategoryModule 的规范,为什么第三方库能找到,我们自己的组件库就找不到呢?原因是第三方库已经上传到云端,CocoaPods 通过索引找到第三方库云端的位置就可以从云端导入本地了;而我们创建的基础组件库并没有上传到云端,而是是在我们本地,CocoaPods 在云端找不到的,所以我们得告诉 CocoaPods,去本地找。
来到 MKCommonUIModule 的 Podfile 文件,如图所示:
在创建 MKCommonUIModule 的时候,CocoaPods 已经帮我们把 MKCommonUIModule 组件库的导入写好了,就是在 Podfile 文件所在目录的上一层目录。那 MKMacroAndCategoryModule 所在的目录如图所示:
因为是在 Podfile 文件的上上层目录中,所以指定的目录是 '../../MKMacroAndCategoryModule'
,此时重新 pod install
,如图所示:
已经导入成功了,重新编译项目,编译成功!
六、组件库加载资源文件
我们重新创建一个 MKTestModule,在 MKTestModule 的 Classes 同级目录中,有一个 Assets 文件,很显然,这个文件就是用来存放资源文件的,将 .xcassets 文件放到该目录下(记得把同目录下 Classes 文件夹中的 ReplaceMe.m 删除,否则到时候加载不出图像的),如图所示:
然后在 .podspec 文件中指定该资源文件的路径,如图所示:
配置完成之后,需要重新 pod install
,重新导入成功之后,我们来看一下,如图所示:
在 Pods 的 MKTestModule 目录下,生成了一个 Images.xcassets 的文件,这个文件就是刚刚放在 Assets 文件夹中的文件。
回到如何加载资源文件的问题,先来看看,我们使用普通的方法,能不能加载出来;在 MKViewController 中添加一个居中的 UIImageView 用于测试,如图所示:
此时,程序已经运行,但是图像并未显示在屏幕上;这是因为[UIImage imageNamed:]
方法默认是在 main bundle 中加载资源文件的,而 MKTestModule 的资源文件很明显并不在 main bundle 中,而是在 MKTestModule 中;所以我们需要拿到 MKTestModule 的 bundle 路径,通过这个路径去加载图。如图所示:
这个时候就能够把图像加载出来了,那么,对于 xib 其实也是一样的,只不过 xib 存放的目录不在 Assets 文件夹中,而是在 Classes 文件夹中,和代码存放在一起,但使用的时候也需要在 .podspec 文件中指定 xib 的目录,例如:
七、组件间的通讯
7.1 组件化通信方案
组件化开发会遇到一些情况,比如组件间的通讯。现有4个模块 A、B、C、D,它们出现了相互跳转的情况,这个时候就会出现一个问题:模块耦合,如图:
产生这种模块间通信耦合我们一般使用一个中间层、或者说调用者去处理:
处理模块通讯间出现的耦合,目前主流的主要有以下三种方式:
-
- URL 路由
-
- target-action
-
- protocol-class
7.2 URL 路由
URL 路由的方式主要讲以蘑菇街为代表的 MGJRouter,目前 MGJRouter 在 Github 上已经找不到了,并且关于 MGJRouter 的作者发布的文章也找不到了,猜测应该是蘑菇街不公开 MGJRouter,但是 Github 上有开发者留下了相应的 Demo,还是可以参考一下的。 除了MGJRouter,还有以下这些三方框架:
先来看 MGJRouter 的两个主要方法:
swift
/**
* 注册 URLPattern 对应的 Handler,在 handler 中可以初始化 VC,然后对 VC 做各种操作
*
* @param URLPattern 带上 scheme,如 mgj://beauty/:id
* @param handler 该 block 会传一个字典,包含了注册的 URL 中对应的变量。
* 假如注册的 URL 为 mgj://beauty/:id 那么,就会传一个 @{@"id": 4} 这样的字典过来
*/
+ (void)registerURLPattern:(NSString *)URLPattern toHandler:(MGJRouterHandler)handler;
/**
* 打开此 URL
* 会在已注册的 URL -> Handler 中寻找,如果找到,则执行 Handler
*
* @param URL 带 Scheme,如 mgj://beauty/3
*/
+ (void)openURL:(NSString *)URL;
其实就两个步骤,register 和 open,其思路很简单:
- 1、创建一个 ModuleManager,App 启动时实例化各个组件,向 ModuleManager 注册 url。
- 2、当组件 A 需要调用组件 B 的时候,需要向 ModuleManager 传递 url,参数跟随URL以GET方式传递,类似openURL。然后由ModuleManager负责调度组件B,最后完成任务。
优点:
- 极高的动态性,适合经常开展运营活动的app,例如电商
- 方便地统一管理多平台的路由规则
- 易于适配URL Scheme
缺点:
- 传参方式有限,并且无法利用编译器进行参数类型检查,因此所有的参数都是通过字符串转换而来
- 只适用于界面模块,不适用于通用模块
- 参数的格式不明确,是个灵活的 dictionary,也需要有个地方可以查参数格式。
- 不支持 storyboard
- 依赖于字符串硬编码,难以管理,蘑菇街做了个后台专门管理。
- 无法保证所使用的的模块一定存在。
- 解耦能力有限,url 的"注册"、"实现"、"使用"必须用相同的字符规则,一旦任何一方做出修改都会导致其他方的代码失效,并且重构难度大。
7.3 target-action
target-action 主要以 casatwy 的 CTMediator 的框架为代表,这个方案基于 OC runtime 的特性动态的获取模块,通过 NSClassFromString 获取类并创建实例,然后通过 performSelector:withObject: 来调用方法。特殊返回值例如:void、NSInteger、BOOL 等使用 NSInvocation 进行处理。我们先来看这个框架如何使用:
- 首先要有一个提供服务的模块 C,模块 C 中有一个专门提供功能入口的对象,起名为 Target_C,这里需要注意:命名要以 Target_ + 模块名称,这个对象主要就是暴露给 CTMediator+Category 的功能接口。
- 给 CTMediator 添加一个 Category,这个地方就是提供给其他模块调用的接口声明,通过调用 CTMediator 的 performTarget 方法,找到对应模块的功能方法进行调用。
所以当模块 A 需要调用模块 C 的功能的时候只需要通过 CTMediator+Category 进行调用,如图所示:
其实 CTMediator 的优缺点也很明显: 优点:
- 利用分类可以明确声明接口,进行编译检查。
- 实现方式轻量。
缺点:
- 需要在 mediator 和 target 中重新添加每一个接口,模块化时代码较为繁琐。
- 在 category 中仍然引入了字符串硬编码,内部使用字典传参,一定程度上也存在和 URL 路由相同的问题。
- 无法保证使用的模块一定存在,target 在修改后,使用者只能在运行时才能发现错误。
- 可能会创建过多的 target 类。
CTMediator 的源码很简单,三个接口,两百多行的代码,这里直接贴代码,有注释:
swift
- (id)performTarget:(NSString *)targetName action:(NSString *)actionName params:(NSDictionary *)params shouldCacheTarget:(BOOL)shouldCacheTarget
{
if (targetName == nil || actionName == nil) {
return nil;
}
//在swift中使用时,需要传入对应项目的target名称,否则会找不到视图控制器
NSString *swiftModuleName = params[kCTMediatorParamsKeySwiftTargetModuleName];
// generate target 生成target
NSString *targetClassString = nil;
if (swiftModuleName.length > 0) {
//swift中target文件名拼接
targetClassString = [NSString stringWithFormat:@"%@.Target_%@", swiftModuleName, targetName];
} else {
//OC中target文件名拼接
targetClassString = [NSString stringWithFormat:@"Target_%@", targetName];
}
//缓存中查找target
NSObject *target = [self safeFetchCachedTarget:targetClassString];
//缓存中没有target
if (target == nil) {
//通过字符串获取对应的类
Class targetClass = NSClassFromString(targetClassString);
//创建实例
target = [[targetClass alloc] init];
}
// generate action 生成action方法名称
NSString *actionString = [NSString stringWithFormat:@"Action_%@:", actionName];
//通过方法名字符串获取对应的sel
SEL action = NSSelectorFromString(actionString);
if (target == nil) {
// 这里是处理无响应请求的地方之一,这个demo做得比较简单,如果没有可以响应的target,就直接return了。实际开发过程中是可以事先给一个固定的target专门用于在这个时候顶上,然后处理这种请求的
[self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
return nil;
}
//是否需要缓存
if (shouldCacheTarget) {
[self safeSetCachedTarget:target key:targetClassString];
}
//是否响应sel
if ([target respondsToSelector:action]) {
//动态调用方法
return [self safePerformAction:action target:target params:params];
} else {
// 这里是处理无响应请求的地方,如果无响应,则尝试调用对应target的notFound方法统一处理
SEL action = NSSelectorFromString(@"notFound:");
if ([target respondsToSelector:action]) {
return [self safePerformAction:action target:target params:params];
} else {
// 这里也是处理无响应请求的地方,在notFound都没有的时候,这个demo是直接return了。实际开发过程中,可以用前面提到的固定的target顶上的。
[self NoTargetActionResponseWithTargetString:targetClassString selectorString:actionString originParams:params];
@synchronized (self) {
[self.cachedTarget removeObjectForKey:targetClassString];
}
return nil;
}
}
}
- (id)safePerformAction:(SEL)action target:(NSObject *)target params:(NSDictionary *)params
{
//获取方法签名
NSMethodSignature* methodSig = [target methodSignatureForSelector:action];
if(methodSig == nil) {
return nil;
}
//获取方法签名中的返回类型,然后根据返回值完成参数传递
const char* retType = [methodSig methodReturnType];
//void类型
if (strcmp(retType, @encode(void)) == 0) {
...
}
//...省略其他类型的判断
}
7.4 protocol-class
- 增加 protocol wrapper层 (中间件先注册 protocol 和 class 对应关系,将 protocol 和对应的类进行字典匹配)。
- 中间件返回 protocol 对应的 class,然后动态创建实例。
- 解决硬编码的问题。
protocol-class 简单示例
swift
//具体的Protocol
//MTMediator.h --- start
@protocol MTDetailViewControllerProtocol <NSObject>
+ (__kindof UIViewController *)detailViewControllerWithUrl:(NSString *)detailUrl;
@end
@interface MTMediator : NSObject
+ (void)registerProtol:(Protocol *)protocol class:(Class)cls;
+ (Class)classForProtocol:(Protocol *)protocol;
@end
//MTMediator.h --- end
//MTMediator.m --- start
+ (void)registerProtol:(Protocol *)protocol class:(Class)cls{
if (protocol && cls) {
[[[self class] mediatorCache] setObject:cls forKey:NSStringFromProtocol(protocol)];
}
}
+ (Class)classForProtocol:(Protocol *)protocol{
return [[[self class] mediatorCache] objectForKey:NSStringFromProtocol(protocol)];
}
//MTMediator.m --- end
//被调用
//MTDetailViewController.h --- start
@protocol MTDetailViewControllerProtocol;
@interface MTDetailViewController : UIViewController<MTDetailViewControllerProtocol>
@end
//MTDetailViewController.h --- end
//MTDetailViewController.m --- start
+ (void)load {
[MTMediator registerProtol: @protocol(MTDetailViewControllerProtocol) class:[self class]];
}
#pragma mark - MTDetailViewControllerProtocol
+ ( __kindof UIViewController *)detailViewControllerWithUrl:(NSString *)detailUrl{
return [[MTDetailViewController alloc]initWithUrlString:detailUrl];
}
//MTDetailViewController.m --- end
//调用
Class cls = [MTMediator classForProtocol: @protocol(MTDetailViewControllerProtocol)];
if ([cls respondsToSelector: @selector(detailViewControllerWithUrl:)]) {
[self.navigationController pushViewController:[cls detailViewControllerWithUrl:item.articleUrl] animated:YES];
}
被调用者先在中间件注册 protocol 和 class 对应关系,对外只暴漏 protocol。
BeeHive
protocol 比较典型的三方框架就是阿里的 BeeHive。BeeHive 借鉴了 Spring Service、Apache DSO 的架构理念,采用 AOP+扩展 App 生命周期 API 形式,将业务功能、基础功能模块以模块方式以解决大型应用中的复杂问题,并让模块之间以 Service 形式调用,将复杂问题切分,以 AOP 方式模块化服务。
BeeHive 核心思想:
- 1、各个模块间调用从直接调用对应模块,变成调用Service的形式,避免了直接依赖。
- 2、App生命周期的分发,将耦合在AppDelegate中逻辑拆分,每个模块以微应用的形式独立存在。
示例如下:
swift
// 1、注册
[[BeeHive shareInstance] registerService:@protocol(HomeServiceProtocol) service:[BHViewController class]];
// 2、使用
#import "BHService.h"
id< HomeServiceProtocol > homeVc = [[BeeHive shareInstance] createService:@protocol(HomeServiceProtocol)];
优点:
- 1、利用接口调用,实现了参数传递时的类型安全。
- 2、直接使用模块的protocol接口,无需再重复封。
缺点:
- 1、用框架来创建所有对象,创建方式不同,即不支持外部传入参数。
- 2、用OC runtime创建对象,不支持swift。
- 3、只做了protocol 和 class 的匹配,不支持更复杂的创建方式 和依赖注入。
- 4、无法保证所使用的protocol 一定存在对应的模块,也无法直接判断某个protocol是否能用于获取模块。
除了BeeHive,还有Swinject。
BeeHive 模块注册
- 在 BeeHive 主要是通过 BHModuleManager 来管理各个模块的。BHModuleManager 中只会管理已经被注册过的模块。
- BeeHive 提供了三种不同的注册形式,Annotation,静态 plist,动态注册。Module、Service 之间没有关联,每个业务模块可以单独实现 Module 或者 Service 的功能。
Annotation 方式注册
这种方式主要是通过 BeeHiveMod 宏进行 Annotation 标记。
swift
//***** 使用
BeeHiveMod(ShopModule)
//***** BeeHiveMod的宏定义
#define BeeHiveMod(name) \
class BeeHive; char * k##name##_mod BeeHiveDATA(BeehiveMods) = ""#name"";
//***** BeeHiveDATA的宏定义
#define BeeHiveDATA(sectname) __attribute((used, section("__DATA,"#sectname" ")))
//***** 全部转换出来后为下面的格式 以name是ShopModule为例
char * kShopModule_mod __attribute((used, section("__DATA,""BeehiveMods"" "))) = """ShopModule""";
这里针对 __attribute
需要说明以下几点:
- 第一个参数 used:用来修饰函数,被 used 修饰以后,意味着即使函数没有被引用,在 Release 下也不会被优化。如果不加这个修饰,那么 Release 环境链接器下会去掉没有被引用的段。
- 通过使用
__attribute__((section("name")))
来指明哪个段。数据则用__attribute__((used))
来标记,防止链接器会优化删除未被使用的段,然后将模块注入到__DATA
中
此时 Module 已经被存储到 Mach-O 文件的特殊段中:
- 进入BHReadConfiguration方法,主要是通过Mach-O找到存储的数据段,取出放入数组中.
swift
NSArray<NSString *>* BHReadConfiguration(char *sectionName,const struct mach_header *mhp)
{
NSMutableArray *configs = [NSMutableArray array];
unsigned long size = 0;
#ifndef __LP64__
// 找到之前存储的数据段(Module找BeehiveMods段 和 Service找BeehiveServices段)的一片内存
uintptr_t *memory = (uintptr_t*)getsectiondata(mhp, SEG_DATA, sectionName, &size);
#else
const struct mach_header_64 *mhp64 = (const struct mach_header_64 *)mhp;
uintptr_t *memory = (uintptr_t*)getsectiondata(mhp64, SEG_DATA, sectionName, &size);
#endif
unsigned long counter = size/sizeof(void*);
// 把特殊段里面的数据都转换成字符串存入数组中
for(int idx = 0; idx < counter; ++idx){
char *string = (char*)memory[idx];
NSString *str = [NSString stringWithUTF8String:string];
if(!str)continue;
BHLog(@"config = %@", str);
if(str) [configs addObject:str];
}
return configs;
}
- 注册的dyld_callback回调.
swift
static void dyld_callback(const struct mach_header *mhp, intptr_t vmaddr_slide)
{
NSArray *mods = BHReadConfiguration(BeehiveModSectName, mhp);
for (NSString *modName in mods) {
Class cls;
if (modName) {
cls = NSClassFromString(modName);
if (cls) {
[[BHModuleManager sharedManager] registerDynamicModule:cls];
}
}
}
//register services
NSArray<NSString *> *services = BHReadConfiguration(BeehiveServiceSectName,mhp);
for (NSString *map in services) {
NSData *jsonData = [map dataUsingEncoding:NSUTF8StringEncoding];
NSError *error = nil;
id json = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
if (!error) {
if ([json isKindOfClass:[NSDictionary class]] && [json allKeys].count) {
NSString *protocol = [json allKeys][0];
NSString *clsName = [json allValues][0];
if (protocol && clsName) {
[[BHServiceManager sharedManager] registerService:NSProtocolFromString(protocol) implClass:NSClassFromString(clsName)];
}
}
}
}
}
__attribute__((constructor))
void initProphet() {
//_dyld_register_func_for_add_image函数是用来注册dyld加载镜像时的回调函数,在dyld加载镜像时,会执行注册过的回调函数
_dyld_register_func_for_add_image(dyld_callback);
}
读取本地 pilst 文件
- 1、需要设置好路径:
swift
[BHContext shareInstance].moduleConfigName = @"BeeHive.bundle/BeeHive";//可选,默认为BeeHive.bundle/BeeHive.plist
- 2、创建 plist 文件,plist 文件的格式也是数组中包含多个字典。字典里面有两个 Key,一个是
moduleLevel
,另一个是moduleClass
。注意根的数组的名字叫moduleClasses
。
- 3、进入 loadLocalModules 方法,主要是从 plist 里面取出数组,然后把数组加入到 BHModuleInfos 数组里面。
swift
//初始化context时,加载Modules和Services
-(void)setContext:(BHContext *)context
{
_context = context;
static dispatch_once_t onceToken;
dispatch_once(&onceToken, ^{
[self loadStaticServices];
[self loadStaticModules];
});
}
👇
//加载modules
- (void)loadStaticModules
{
// 读取本地plist文件里面的Module,并注册到BHModuleManager的BHModuleInfos数组中
[[BHModuleManager sharedManager] loadLocalModules];
//注册所有modules,在内部根据优先级进行排序
[[BHModuleManager sharedManager] registedAllModules];
}
👇
- (void)loadLocalModules
{
//plist文件路径
NSString *plistPath = [[NSBundle mainBundle] pathForResource:[BHContext shareInstance].moduleConfigName ofType:@"plist"];
//判断文件是否存在
if (![[NSFileManager defaultManager] fileExistsAtPath:plistPath]) {
return;
}
//读取整个文件[@"moduleClasses" : 数组]
NSDictionary *moduleList = [[NSDictionary alloc] initWithContentsOfFile:plistPath];
//通过moduleClasses key读取 数组 [[@"moduleClass":"aaa", @"moduleLevel": @"bbb"], [...]]
NSArray<NSDictionary *> *modulesArray = [moduleList objectForKey:kModuleArrayKey];
NSMutableDictionary<NSString *, NSNumber *> *moduleInfoByClass = @{}.mutableCopy;
//遍历数组
[self.BHModuleInfos enumerateObjectsUsingBlock:^(NSDictionary * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
[moduleInfoByClass setObject:@1 forKey:[obj objectForKey:kModuleInfoNameKey]];
}];
[modulesArray enumerateObjectsUsingBlock:^(NSDictionary * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if (!moduleInfoByClass[[obj objectForKey:kModuleInfoNameKey]]) {
//存储到 BHModuleInfos 中
[self.BHModuleInfos addObject:obj];
}
}];
}
load 方法注册
该方法注册Module就是在Load方法里面注册Module的类.
swift
+ (void)load
{
[BeeHive registerDynamicModule:[self class]];
}
进入registerDynamicModule实现.
swift
+ (void)registerDynamicModule:(Class)moduleClass
{
[[BHModuleManager sharedManager] registerDynamicModule:moduleClass];
}
👇
- (void)registerDynamicModule:(Class)moduleClass
{
[self registerDynamicModule:moduleClass shouldTriggerInitEvent:NO];
}
👇
- (void)registerDynamicModule:(Class)moduleClass
shouldTriggerInitEvent:(BOOL)shouldTriggerInitEvent
{
[self addModuleFromObject:moduleClass shouldTriggerInitEvent:shouldTriggerInitEvent];
}
和 Annotation 方式注册的 dyld_callback 回调一样,最终会走到 addModuleFromObject:shouldTriggerInitEvent: 方法中.
swift
shouldTriggerInitEvent:(BOOL)shouldTriggerInitEvent
{
Class class;
NSString *moduleName = nil;
if (object) {
class = object;
moduleName = NSStringFromClass(class);
} else {
return ;
}
__block BOOL flag = YES;
[self.BHModules enumerateObjectsUsingBlock:^(id _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
if ([obj isKindOfClass:class]) {
flag = NO;
*stop = YES;
}
}];
if (!flag) {
return;
}
if ([class conformsToProtocol:@protocol(BHModuleProtocol)]) {
NSMutableDictionary *moduleInfo = [NSMutableDictionary dictionary];
BOOL responseBasicLevel = [class instancesRespondToSelector:@selector(basicModuleLevel)];
int levelInt = 1;
if (responseBasicLevel) {
levelInt = 0;
}
[moduleInfo setObject:@(levelInt) forKey:kModuleInfoLevelKey];
if (moduleName) {
[moduleInfo setObject:moduleName forKey:kModuleInfoNameKey];
}
[self.BHModuleInfos addObject:moduleInfo];
id<BHModuleProtocol> moduleInstance = [[class alloc] init];
[self.BHModules addObject:moduleInstance];
[moduleInfo setObject:@(YES) forKey:kModuleInfoHasInstantiatedKey];
[self.BHModules sortUsingComparator:^NSComparisonResult(id<BHModuleProtocol> moduleInstance1, id<BHModuleProtocol> moduleInstance2) {
NSNumber *module1Level = @(BHModuleNormal);
NSNumber *module2Level = @(BHModuleNormal);
if ([moduleInstance1 respondsToSelector:@selector(basicModuleLevel)]) {
module1Level = @(BHModuleBasic);
}
if ([moduleInstance2 respondsToSelector:@selector(basicModuleLevel)]) {
module2Level = @(BHModuleBasic);
}
if (module1Level.integerValue != module2Level.integerValue) {
return module1Level.integerValue > module2Level.integerValue;
} else {
NSInteger module1Priority = 0;
NSInteger module2Priority = 0;
if ([moduleInstance1 respondsToSelector:@selector(modulePriority)]) {
module1Priority = [moduleInstance1 modulePriority];
}
if ([moduleInstance2 respondsToSelector:@selector(modulePriority)]) {
module2Priority = [moduleInstance2 modulePriority];
}
return module1Priority < module2Priority;
}
}];
[self registerEventsByModuleInstance:moduleInstance];
if (shouldTriggerInitEvent) {
[self handleModuleEvent:BHMSetupEvent forTarget:moduleInstance withSeletorStr:nil andCustomParam:nil];
[self handleModulesInitEventForTarget:moduleInstance withCustomParam:nil];
dispatch_async(dispatch_get_main_queue(), ^{
[self handleModuleEvent:BHMSplashEvent forTarget:moduleInstance withSeletorStr:nil andCustomParam:nil];
});
}
}
}
load 方法,还可以使用 BH_EXPORT_MODULE 宏代替.
swift
#define BH_EXPORT_MODULE(isAsync) \
+ (void)load { [BeeHive registerDynamicModule:[self class]]; } \
-(BOOL)async { return [[NSString stringWithUTF8String:#isAsync] boolValue];}
BH_EXPORT_MODULE 宏里面可以传入一个参数,代表是否异步加载 Module 模块,如果是 YES 就是异步加载,如果是 NO 就是同步加载。
BeeHive 模块事件
- BeeHive 会给每个模块提供生命周期事件,用于与 BeeHive 宿主环境进行必要信息交互,感知模块生命周期的变化。
- BeeHive 各个模块会收到一些事件。在 BHModuleManager 中,所有的事件被定义成了 BHModuleEventType 枚举。如下所示,其中有2个事件很特殊,一个是 BHMInitEvent ,一个是 BHMTearDownEvent.
swift
typedef NS_ENUM(NSInteger, BHModuleEventType)
{
//设置Module模块
BHMSetupEvent = 0,
//用于初始化Module模块,例如环境判断,根据不同环境进行不同初始化
BHMInitEvent,
//用于拆除Module模块
BHMTearDownEvent,
BHMSplashEvent,
BHMQuickActionEvent,
BHMWillResignActiveEvent,
BHMDidEnterBackgroundEvent,
BHMWillEnterForegroundEvent,
BHMDidBecomeActiveEvent,
BHMWillTerminateEvent,
BHMUnmountEvent,
BHMOpenURLEvent,
BHMDidReceiveMemoryWarningEvent,
BHMDidFailToRegisterForRemoteNotificationsEvent,
BHMDidRegisterForRemoteNotificationsEvent,
BHMDidReceiveRemoteNotificationEvent,
BHMDidReceiveLocalNotificationEvent,
BHMWillPresentNotificationEvent,
BHMDidReceiveNotificationResponseEvent,
BHMWillContinueUserActivityEvent,
BHMContinueUserActivityEvent,
BHMDidFailToContinueUserActivityEvent,
BHMDidUpdateUserActivityEvent,
BHMHandleWatchKitExtensionRequestEvent,
BHMDidCustomEvent = 1000
};
模块事件主要分三种:
1、系统事件:主要是指 Application 生命周期事件!
一般的做法是 AppDelegate 改为继承自 BHAppDelegate。
swift
@interface TestAppDelegate : BHAppDelegate <UIApplicationDelegate>
2、应用事件:官方给出的流程图,其中 modSetup、modInit 等,可以用于编码实现各插件模块的设置与初始化。
3、自定义事件
以上所有的事件都可以通过调用BHModuleManager的triggerEvent:来处理。
swift
- (void)triggerEvent:(NSInteger)eventType
{
[self triggerEvent:eventType withCustomParam:nil];
}
👇
- (void)triggerEvent:(NSInteger)eventType
withCustomParam:(NSDictionary *)customParam {
[self handleModuleEvent:eventType forTarget:nil withCustomParam:customParam];
}
👇
#pragma mark - module protocol
- (void)handleModuleEvent:(NSInteger)eventType
forTarget:(id<BHModuleProtocol>)target
withCustomParam:(NSDictionary *)customParam
{
switch (eventType) {
//初始化事件
case BHMInitEvent:
//special
[self handleModulesInitEventForTarget:nil withCustomParam :customParam];
break;
//析构事件
case BHMTearDownEvent:
//special
[self handleModulesTearDownEventForTarget:nil withCustomParam:customParam];
break;
//其他3类事件
default: {
NSString *selectorStr = [self.BHSelectorByEvent objectForKey:@(eventType)];
[self handleModuleEvent:eventType forTarget:nil withSeletorStr:selectorStr andCustomParam:customParam];
}
break;
}
}
- 从上面的代码中可以发现,除去 BHMInitEvent 初始化事件和 BHMTearDownEvent 拆除 Module 事件这两个特殊事件以外,所有的事件都是调用的 handleModuleEvent:forTarget:withSeletorStr:andCustomParam: 方法,其内部实现主要是遍历 moduleInstances 实例数组,调用 performSelector:withObject: 方法实现对应方法调用.
swift
- (void)handleModuleEvent:(NSInteger)eventType
forTarget:(id<BHModuleProtocol>)target
withSeletorStr:(NSString *)selectorStr
andCustomParam:(NSDictionary *)customParam
{
BHContext *context = [BHContext shareInstance].copy;
context.customParam = customParam;
context.customEvent = eventType;
if (!selectorStr.length) {
selectorStr = [self.BHSelectorByEvent objectForKey:@(eventType)];
}
SEL seletor = NSSelectorFromString(selectorStr);
if (!seletor) {
selectorStr = [self.BHSelectorByEvent objectForKey:@(eventType)];
seletor = NSSelectorFromString(selectorStr);
}
NSArray<id<BHModuleProtocol>> *moduleInstances;
if (target) {
moduleInstances = @[target];
} else {
moduleInstances = [self.BHModulesByEvent objectForKey:@(eventType)];
}
//遍历 moduleInstances 实例数组,调用performSelector:withObject:方法实现对应方法调用
[moduleInstances enumerateObjectsUsingBlock:^(id<BHModuleProtocol> moduleInstance, NSUInteger idx, BOOL * _Nonnull stop) {
if ([moduleInstance respondsToSelector:seletor]) {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Warc-performSelector-leaks"
//进行方法调用
[moduleInstance performSelector:seletor withObject:context];
#pragma clang diagnostic pop
[[BHTimeProfiler sharedTimeProfiler] recordEventTime:[NSString stringWithFormat:@"%@ --- %@", [moduleInstance class], NSStringFromSelector(seletor)]];
}
}];
}
注意:这里所有的Module必须是遵循BHModuleProtocol的,否则无法接收到这些事件的消息。
BeeHive Protocol 注册
在 BeeHive 中是通过 BHServiceManager 来管理各个 Protocol 的。BHServiceManager 中只会管理已经被注册过的 Protocol。
注册 Protocol 的方式总共有三种,和注册 Module 是一样一一对应的.
Annotation 方式注册
swift
//****** 1、通过BeeHiveService宏进行Annotation标记
BeeHiveService(HomeServiceProtocol,BHViewController)
//****** 2、宏定义
#define BeeHiveService(servicename,impl) \
class BeeHive; char * k##servicename##_service BeeHiveDATA(BeehiveServices) = "{ ""#servicename"" : ""#impl""}";
//****** 3、转换后的格式,也是将其存储到特殊的段
char * kHomeServiceProtocol_service __attribute((used, section("__DATA,""BeehiveServices"" "))) = "{ """HomeServiceProtocol""" : """BHViewController"""}";
读取本地 plist 文件
- 同Module一样,需要先设置好路径.
swift
[BHContext shareInstance].serviceConfigName = @"BeeHive.bundle/BHService";
- 设置 plist 文件.
- 同样也是在 setContext 时注册 services
swift
//加载services
-(void)loadStaticServices
{
[BHServiceManager sharedManager].enableException = self.enableException;
[[BHServiceManager sharedManager] registerLocalServices];
}
👇
- (void)registerLocalServices
{
NSString *serviceConfigName = [BHContext shareInstance].serviceConfigName;
//获取plist文件路径
NSString *plistPath = [[NSBundle mainBundle] pathForResource:serviceConfigName ofType:@"plist"];
if (!plistPath) {
return;
}
NSArray *serviceList = [[NSArray alloc] initWithContentsOfFile:plistPath];
[self.lock lock];
//遍历并存储到allServicesDict中
for (NSDictionary *dict in serviceList) {
NSString *protocolKey = [dict objectForKey:@"service"];
NSString *protocolImplClass = [dict objectForKey:@"impl"];
if (protocolKey.length > 0 && protocolImplClass.length > 0) {
[self.allServicesDict addEntriesFromDictionary:@{protocolKey:protocolImplClass}];
}
}
[self.lock unlock];
}
load 方法注册
在 load 方法里面注册 Protocol 协议,主要是调用 BeeHive 里面的 registerService:service: 完成 protocol 的注册.
swift
+ (void)load
{
[[BeeHive shareInstance] registerService:@protocol(UserTrackServiceProtocol) service:[BHUserTrackViewController class]];
}
👇
- (void)registerService:(Protocol *)proto service:(Class) serviceClass
{
[[BHServiceManager sharedManager] registerService:proto implClass:serviceClass];
}
Protocol 的获取
Protocol 与 Module 的区别在于: Protocol 比 Module 多了一个方法,可以返回 Protocol 实例对象.
swift
- (id)createService:(Protocol *)proto;
{
return [[BHServiceManager sharedManager] createService:proto];
}
👇
- (id)createService:(Protocol *)service
{
return [self createService:service withServiceName:nil];
}
👇
- (id)createService:(Protocol *)service withServiceName:(NSString *)serviceName {
return [self createService:service withServiceName:serviceName shouldCache:YES];
}
👇
- (id)createService:(Protocol *)service withServiceName:(NSString *)serviceName shouldCache:(BOOL)shouldCache {
if (!serviceName.length) {
serviceName = NSStringFromProtocol(service);
}
id implInstance = nil;
//判断protocol是否已经注册过
if (![self checkValidService:service]) {
if (self.enableException) {
@throw [NSException exceptionWithName:NSInternalInconsistencyException reason:[NSString stringWithFormat:@"%@ protocol does not been registed", NSStringFromProtocol(service)] userInfo:nil];
}
}
NSString *serviceStr = serviceName;
//如果有缓存,则直接从缓存中获取
if (shouldCache) {
id protocolImpl = [[BHContext shareInstance] getServiceInstanceFromServiceName:serviceStr];
if (protocolImpl) {
return protocolImpl;
}
}
//获取类后,然后响应下层的方法
Class implClass = [self serviceImplClass:service];
if ([[implClass class] respondsToSelector:@selector(singleton)]) {
if ([[implClass class] singleton]) {
if ([[implClass class] respondsToSelector:@selector(shareInstance)])
//创建单例对象
implInstance = [[implClass class] shareInstance];
else
//创建实例对象
implInstance = [[implClass alloc] init];
if (shouldCache) {
//缓存
[[BHContext shareInstance] addServiceWithImplInstance:implInstance serviceName:serviceStr];
return implInstance;
} else {
return implInstance;
}
}
}
return [[implClass alloc] init];
}
createService 会先检查 Protocol 是否是注册过的。然后接着取出字典里面对应的 Class,如果实现了 shareInstance 方法,那么就创建一个单例对象,如果没有,那么就创建一个实例对象。如果还实现了 singleton,就能进一步的把 implInstance 和 serviceStr 对应的加到 BHContext 的 servicesByName 字典里面缓存起来。这样就可以随着上下文传递了.
进入 serviceImplClass 实现,从这里可以看出 protocol 和类是通过字典绑定的,protocol 作为 key,serviceImp(类的名字)作为 value.
swift
- (Class)serviceImplClass:(Protocol *)service
{
//通过字典将 协议 和 类 绑定,其中协议作为key,serviceImp(类的名字)作为value
NSString *serviceImpl = [[self servicesDict] objectForKey:NSStringFromProtocol(service)];
if (serviceImpl.length > 0) {
return NSClassFromString(serviceImpl);
}
return nil;
}
Module & Protocol
-
对于 Module:数组存储.
-
对于 Protocol:通过字典将 protocol 与类进行绑定,key 为 protocol,value 为 serviceImp 即类名.
BeeHive 辅助类
- BHContext 类:是一个单例,其内部有两个 NSMutableDictionary 的属性,分别是 modulesByName 和 servicesByName。这个类主要用来保存上下文信息的。例如在 application:didFinishLaunchingWithOptions: 的时候,就可以初始化大量的上下文信息.
swift
//保存信息
[BHContext shareInstance].application = application;
[BHContext shareInstance].launchOptions = launchOptions;
[BHContext shareInstance].moduleConfigName = @"BeeHive.bundle/BeeHive";//可选,默认为BeeHive.bundle/BeeHive.plist
[BHContext shareInstance].serviceConfigName = @"BeeHive.bundle/BHService";
- BHConfig 类:是一个单例,其内部有一个 NSMutableDictionary 类型的 config 属性,该属性维护了一些动态的环境变量,作为 BHContext 的补充存在.
- BHTimeProfiler 类:用来进行计算时间性能方面的 Profiler.
- BHWatchDog 类:用来开一个线程,监听主线程是否堵塞.
参考链接
八、题外话
- 目前做 iOS 开发还有希望吗?互联网行情是不是越来越差了?
- 欢迎评论~