Mac系统的环境变量,加载顺序

Mac系统的环境变量,加载顺序为:
a. /etc/profile
b. /etc/paths
c. ~/.bash_profile
d. ~/.bash_login
e. ~/.profile
f. ~/.bashrc
其中a和b是系统级别的,系统启动就会加载,其余是用户接别的。c,d,e按照从前往后的顺序读取,如果c文件存在,则后面的几个文件就会被忽略不读了,以此类推。~/.bashrc没有上述规则,它是bash shell打开的时候载入的。这里建议在c ~/.bash_profile 中添加环境变量

node 版本管理n

From: https://www.jianshu.com/p/a52bb03cb279

Node.js 可谓风光无限,但是版本管理很是头大,不同的项目用不同的Node版本,往往Node版本间不互相兼容,所以经常需要在不同的Node版本中切换。

Mac下你同样可以通过nvm来管理Node版本,今天介绍另一个 n 模块来管理Node版本。

n 介绍

n 是Node的一个模块,所以安装非常方便,而且作者是Express框架的作者写的。

安装

既然是Node模块,直接通过npm安装全局。

$ sudo npm install -g n

常用命令

查看帮助

$ n help

列出所有的Node 版本

$ n ls

安装某个版本

$ n xx.xx.x (xx.xx.x 为要安装的版本号)

安装最新版本

$ n lastest

安装最新稳定版

$ n stable

选取已安装的版本

$ n

然后上下键盘选择并回车确认。

删除某个版本

$ n rm xx.xx.x

制定版本来运行脚本

$ n use xx.xx.x a.js

推荐阅读更多精彩内容

Node版本的迭代速度很快,版本很多(横跨0.6到0.11),升级Node版本成为了一个问题。目前有n和nvm这两…

礼落阅读 566评论 0赞 0

安装好node.js后,一般我们可以通过n模块和nvm这两个工具对它的版本进行管理。 nvm的使用比较复杂,所以比…

南魂09阅读 5,479评论 3赞 6

通常情况下,当我们有多个项目来维护时,如果各个项目所使用的node版本不一样,这就需要我们同时来管理多个node版…

宇行信阅读 454评论 0赞 0

暂且把这篇文章叫做这个长相有点像胡歌的男人吧,毕竟s先生很自恋,不过就是有一对又长又翘的睫毛,还有个类似于胡歌脸型…

96ff737f6c9b阅读 576评论 4赞 2

太阳把我的影子晒软 在身后 一言不发 不尽的旅途 把它拖成一个人 看不到尽头的路 在远处弯成一个圆圈 一条孤傲的蛇…

蔚蓝_0e68阅读 82评论 0赞 1

前几天去大连,到星海广场走走,随手拍了几张大连的建筑。 这是站在跨海大桥上拍摄的。司机师傅人不错,看到我很想下车拍…

故乡月阅读 140评论 0赞 3

Xcode 12 老项目pod提示头文件找不到

From: https://blog.csdn.net/chokshen/article/details/108843444

因为Xcode12默认将Architectures设置为了$(ARCHS_STANDARD) ,而废除了之前的VALID_ARCHS,所以需要更新相应的配置。

首先第一步要做的就是,删除之前的VALID_ARCHS,选中TARGETS—Build Settings—VALID_ARCHS,选中VALID_ARCHS按Delete键删除。

注意:如果之前是在项目PROJECT设置的VALID_ARCHS,请选中项目PROJECT—Build Settings—VALID_ARCHS

然后选中PROJECT**—Build Settings—Excluded Architecture,设置debug模式下Any iOS Simulator SDK的值为arm64release模式下Any iOS SDK的值为armv7(因为iOS14 SDK不再支持armv7架构的手机,所以需要将armv7剔除)**。

最后一步,参照上面的流程,Pods工程也需要做同样的操作。选中Pods的**PROJECT—Build Settings—Excluded Architecture**,手动添加相应的配置:

但是手动配置的话,每次pod install后,配置将会被覆盖,所以推荐在podfile里面添加以下配置,这样每次pod install完成后,pod会为你自动添加相应的配置。

post_install do |installer|
  installer.pods_project.build_configurations.each do |config|
    if config.name == "Debug" then config.build_settings["EXCLUDED_ARCHS[sdk=iphonesimulator*]"] = "arm64"
    else if config.name == "Release" then
      config.build_settings["EXCLUDED_ARCHS[sdk=iphoneos*]"] = "armv7"
    end
  end

注意:在使用pod install后,有时候会造成主项目PROJECT下Excluded Architecture配置被覆盖,所以pod install后最好是检查一下主项目的PROJECT配置,以确保万无一失。

参考资料:https://stackoverflow.com/questions/63607158/xcode-12-building-for-ios-simulator-but-linking-in-object-file-built-for-ios

flutter 模块化接入流程

flutter 模块化接入流程

1 … 环境搭建

2 新建module模式的flutter 项目

3 pod 方式接入

podfile 文件

1
2
flutter_application_path = '../XXX/flutter目录'
load File.join(flutter_application_path, '.ios', 'Flutter', 'podhelper.rb')

4 使用

导入头文件
import Flutter
import FlutterPluginRegistrant

ios 15 适配

From: https://tommygirl.cn/2021/07/20/iOS15/

苹果前两天推出了iOS 15的beta-3。这个消息还是从客户那儿知道的😂,没想到他们比我还前卫,秋天都等不及~~

测试背景

Xcode 13 beta版,iOS 15 beta 3的系统。
除了客户提出的问题,自己还发现了两处UI异常,不过说不定苹果能良心发现,在正式版中给修复一下。

一、企业签名的 App 无法使用

客户反馈说 App 不能正常打开,并且提示下面的这种信息:

“xxx”Needs to Be Updated : The developer of this app needs to update it to work with this version of iOS.

“xxx”需要更新 :App开发者需要更新此App以在此iOS版本上正常工作。

猜测是需要重签名或者使用最新的 Xcode 打包。

我看苹果论坛上有人说必须使用新的 Xcode 以适配 iOS 15,但我用旧版的 Xcode 重新打了一个包,也可以解决这个问题。所以,不方便更新 Xcode 的话,可以尝试重签名试试。

睡了一觉之究极补充: 签名问题与 Xcode 版本无关,而是 Mac 系统导致的,将 macOS 升级到 11.* Big Sur 以上再进行重新签名,可以解决 App 无法使用的问题。

二、 NavigationBar 颜色及背景失效

1. 问题描述

项目中往往会自定义一个导航控制器,方便全局指定导航条的背景色、标题颜色等等。以设置背景色和标题颜色为例:

1  

2  

3  

4  

5  





self.navigationBar.barTintColor = RGB(42, 109, 240);  



NSDictionary *titleTextAttributes = @{NSFontAttributeName:[UIFont fontWithName:@"" size:18], NSForegroundColorAttributeName:RGB(255, 255, 255)};  

[self.navigationBar setTitleTextAttributes:titleTextAttributes];  

但在 iOS 15上发现,指定的背景色失效了,但滚动控制器的视图时,导航条的背景又出现了。看了一眼 UINavigationBar 的 API,15中并没有新增的。倒是有几个 iOS 13新增的 API 我没用过……哈哈哈,写到这里觉得自己以前的功课落下太多了,13的更新还没学习呢😂😂😂😂😂

2. iOS 13新增 API

  • standardAppearance : 描述导航栏以标准高度显示时要使用的外观属性。

    1

@property (nonatomic, readwrite, copy) UINavigationBarAppearance *standardAppearance;  
  • compactAppearance : 描述导航栏在紧凑高度时使用的外观属性。如果未设置,则将使用标准外观。

    1

@property (nonatomic, readwrite, copy, nullable) UINavigationBarAppearance *compactAppearance;  
  • scrollEdgeAppearance : 描述当关联的 UIScrollView 向上滚动时要使用的导航栏的外观属性。如果未设置,将改用修改后的standardAppearance。

    1

@property (nonatomic, readwrite, copy, nullable) UINavigationBarAppearance *scrollEdgeAppearance;  
  • compactScrollEdgeAppearance : 描述当导航栏以紧凑的高度显示时,以及关联的 UIScrollView 往上滚动时,要使用的导航栏的外观属性。如果未设置,则首先尝试 scrollEdgeAppearance,如果为nil,则尝试 compactAppearance,然后尝试修改 standardAppearance。

    1

@property(nonatomic,readwrite, copy, nullable) UINavigationBarAppearance *compactScrollEdgeAppearance;  

3. 解决办法

根据我们的问题现象,猜测是 standardAppearancescrollEdgeAppearance 需要调整,如果正常状态和滚动状态颜色一样,可以修改如下:

NSDictionary *titleTextAttributes = @{NSFontAttributeName:[UIFont fontWithName:MAIN_FONT_FAMILY size:18], NSForegroundColorAttributeName:RGB(255, 255, 255)};  

if (@available(iOS 13.0, *)) {  

    UINavigationBarAppearance *appearance = [UINavigationBarAppearance new];  

    appearance.backgroundColor = RGB(42, 109, 240);  

    appearance.titleTextAttributes = titleTextAttributes;      

    self.navigationBar.standardAppearance = appearance;  

    self.navigationBar.scrollEdgeAppearance = appearance;  

} else {  



    self.navigationBar.barTintColor = RGB(42, 109, 240);  

    [self.navigationBar setTitleTextAttributes:titleTextAttributes];  

}  

Bingo!颜色显示正常啦

所以这是什么意思?强买强卖吗?必须设置 Appearance 才可以?

4. 遗留问题

在 Xcode 13-beta 中,必须同时指定 standardAppearancescrollEdgeAppearance 才可以。但根据苹果的注释,如果 scrollEdgeAppearance 为nil,会默认使用 standardAppearance 啊。燃鹅并不行。不知道是苹果的 bug 还是怎么的……朋友昨天叫我一起转行了,因为他觉得苹果的系统做的一年不如一年~ 😂

三、UITabBar 背景图失效

这个问题有点类似上一个,UITabBar 之前设置的背景图片,老版本可以,iOS 15上表现为空白。参考问题二的思路,找到了下面的 API,做个兼容就可以了。当然,遗留问题同上,必须同时指定 standardAppearancescrollEdgeAppearance 才可以……🙄……而且,如果在初始化以后,某个时机单独修改了 standardAppearance,也必须要同步指定一下 scrollEdgeAppearance ……🙄

  • API
@property (nonatomic, readwrite, copy) UITabBarAppearance *standardAppearance;  

@property (nonatomic, readwrite, copy, nullable) UITabBarAppearance *scrollEdgeAppearance;  
  • 老方式

    1

[self.tabBar setBackgroundImage:[img imageWithRenderingMode:(UIImageRenderingModeAlwaysOriginal)]];  
  • 兼容新的API
UIImage *img = [UIImage imageNamed:@"ahaaaaa"];  

if (@available(iOS 13.0, *)) {  

    UITabBarAppearance *appearance = [[UITabBarAppearance alloc] init];  

    appearance.backgroundImage = img;  

    appearance.backgroundImageContentMode = UIViewContentModeScaleToFill;  

    self.tabBar.standardAppearance = appearance;  

    if (@available(iOS 15.0, *)) {  

        self.tabBar.scrollEdgeAppearance = appearance;  

    } else {  



    }  

} else {  



    [self.tabBar setBackgroundImage:[img imageWithRenderingMode:(UIImageRenderingModeAlwaysOriginal)]];  

}  

四、UITabBarItem 文字颜色失效

……还是同上,新版本中 UITabBarItem 文字颜色的修改不起作用。同样是在 iOS 13 中新增的 UITabBarItemAppearance 来修改 Item 的不同状态下的不同表现。遗留问题同上。

  • 相关的类型如下,其他的API就不贴了:

UITabBarItemAppearanceUITabBarItemStateAppearance

@property (nonatomic, readonly, strong) UITabBarItemStateAppearance *normal;  





@property (nonatomic, readonly, strong) UITabBarItemStateAppearance *selected;  





@property (nonatomic, readonly, strong) UITabBarItemStateAppearance *disabled;  





@property (nonatomic, readonly, strong) UITabBarItemStateAppearance *focused;  
  • 兼容新的API
UIColor *normalTitleColor = RGBA(80, 80, 81, 1);  

UIColor *selectedTitleColor = RGBA(42, 109, 240, 1);  

if (@available(iOS 13.0, *)) {  

    UITabBarItemAppearance *itemAppearance = [[UITabBarItemAppearance alloc] init];  

    itemAppearance.normal.titleTextAttributes = @{NSForegroundColorAttributeName : normalTitleColor};  

    itemAppearance.selected.titleTextAttributes = @{NSForegroundColorAttributeName : selectedTitleColor};  

    UITabBarAppearance *appearance = [[UITabBarAppearance alloc] init];  

    appearance.stackedLayoutAppearance = itemAppearance;  

    self.tabBar.standardAppearance = appearance;  

    if (@available(iOS 15.0, *)) {  

        self.tabBar.scrollEdgeAppearance = appearance;  

    } else {  



    }  

}else if (@available(iOS 10.0, *)) {  

    self.tabBar.tintColor = normalTitleColor;  

    self.tabBar.unselectedItemTintColor = selectedTitleColor;  

}else {  



}  

五、 UITableView Header 高度失效

通常 TableView 第一个分组如果不需要 Header 的话,我们会给个0.01的高度,看上去就是顶部没有空白的效果。

- (CGFloat)tableView:(UITableView *)tableView heightForHeaderInSection:(NSInteger)section {  

    return 0.01;  

}  

但在 iOS 15上视图的顶部默认会下沉几个像素,我以为磨人的 automaticallyAdjustsScrollIndicatorInsets 这类小妖精又出现了,尝试了一下好像不是这类问题。😂

看了一眼官网的 API 变动,发现了一个小秘密:

The amount of padding above each section header.

每个分组Header上方的填充量。

@property(nonatomic) CGFloat sectionHeaderTopPadding;  

所以我就这样:

if (@available(iOS 15.0, *)) {  

    self.tableView.sectionHeaderTopPadding = 0;  

}  

试着改了一下,毕竟试试又不会怀孕。好了……有那么一瞬间我仿佛能理解苹果为什么加这个属性,貌似真的有场景会用到这个间距。冷静了一下,我发现我还是太菜了,理解不了~

六、UITextField 的 clearButton 向右偏移

_UITextFieldClearButton 向右偏移了一点儿点儿…有点压到边框,倒是不影响使用,然后我也不知道怎么改,谁知道告诉我一下。阿里嘎多~

2021-08-12:关于问题六,iOS 15 beta-4 版本中貌似已经好了~~


目前就发现了这几个问题。希望秋天到来的时候,开发者们不用花太多时间在 UI 适配上。

记录一次pod 私有组件,导入第三方framework的问题

记录一次pod 私有组件,导入第三方framework的问题

1 起因: UM 的超链裂变是beta 版本 需要文件导入, 本身项目是一个组件化项目,所以um的framework 不能导入在主项目中,

按照调理接入

1
2
3
4
5
6
7
8
# 私有库加入
s.vendored_frameworks = [
"xxx/Source/UM/common/common_ios_7.2.7/UMCommon.framework",
"xxx/Source/UM/common/common_ios_7.2.7/UMRemoteConfig.framework",
"xxx/Source/UM/common/common_ios_7.2.7/UMDevice.framework",
"xxx/Source/UM/link/link_ios_1.0.0/UMLink.framework",
] #自己的framework在工程中的路径

然后发现头文件重复导入, 和一些奇怪的找不到的问题 然后去处理头文件, 后发现 -umbrella.h 把 一些头文件按照”xxx.h” 方式导入,造成找不到的问题, 然后使用

1
s.private_header_files = ['xxx/Source/UM/common/common_ios_7.2.7/**/*.h','xxx/Source/UM/**/*.h']

私有头文件, -umbrella.h 中不会有多余的头文件了, 运行, 成功, 但是 主要是因为 主项目用的 use_modular_headers!
非 use_frameworks! 如果是 use_frameworks! 还需要做一个头文件搜索路径

https://www.jianshu.com/p/dfe9a1e1db7f 这个地址有详细说明, 后续没有继续去处理

Include of non-modular header inside framework module

From: https://alanli7991.github.io/2017/07/21/%E6%A8%A1%E5%9D%97%E5%8C%9621Framework%E4%B8%8E%E6%A8%A1%E5%9D%97%E5%A4%96nonmodular%E5%A4%B4%E6%96%87%E4%BB%B6/

Include of non-modular header inside framework module

在进行Framework化的过程中,一旦引用了某些Framework其使用者Project,就会报错

HttpHelper.h:10:9: error : include of non-modular header inside framework module 'Ware.HttpHelper': '/Applications/Xcode.app/Contents/Developer/Platforms/iPhoneSimulator.platform/Developer/SDKs/iPhoneSimulator10.3.sdk/usr/include/CommonCrypto/CommonHMAC.h'  

[-Werror,-Wnon-modular-include-in-framework-module] #import <CommonCrypto/CommonHMAC.h>  

提醒你 在Framework Module中加载了非当前Module的头文件

处理方法

处理方法有两种,一种是基于LLVM的配置,一种是手动梳理头文件

Allow Non-modular Include

根据StackOverflow的回答可以通过

  • Build Setting — Apple LLVM 8.1 Language Modules — Allow Non-modular Includes In Framework Modules

设置为YES,则可以在Framework中使用模块外的Include,不过这种过于粗暴

检查头文件引用关系

根据下文产生原因的分析,其本质原因类似C++文件file not found的加载与编译,是因为不同语言之间的头文件引用混乱产生的

手工检查头文件引用关系,特别是 Umbrella Header File中 不要引用Include 了 C/C++ 头文件的OC头文件 作为子模块放出

产生原因

关注点集中于 #import 可以发现其 CommonCrypto 模块为一个系统库

与UIKit这样的Framework不同,CommonCrypto 是 C模块 ,也就是说其头文件 CommonHMAC.h 不应该伴随 SomeHeader.h 头文件对外暴露

  1. Target.app 引用了 SharedFrame.framework的Umbrella头文件 SharedFrame.h
  2. Umbrella头文件中包含了 SomeHeader.h
  3. HttpHelper.h 中暴露了 CommonCrypto 的 CommonHMAC.h
  4. 对于 SharedFrame.framework的Scope, 内部二进制包含有 CommonCrypto 模块的代码,CommonHMAC.h有效
  5. 对于 Target.app的Scope, 不确定SharedFrame.h内部引用 HttpHelper.h 是否包含CommonCrypto 模块的代码
  6. Target.app 判定 为模块外Header File报错

修改方法:

在 SharedFrame.h 中删除 #import , 仅在 Target.app 的.m文件中使用时加载

iOS动态调用类方法

From: https://www.jianshu.com/p/a43057a8d474

iOS动态调用类方法(不带参数)

Class class = NSClassFromString(@"MyClass");
if (class) {
    id obj = [[class alloc] init];
    SEL sel = NSSelectorFromString(@"myMethod");
    //检查是否有"myMethod"这个名称的方法
    if ([obj respondsToSelector:sel]) {
        [obj performSelector:sel];
    }
}

iOS动态调用类方法(带参数)

Class class = NSClassFromString(@"MyClass");
if (class) {
    id obj = [[class alloc] init];
    SEL sel = NSSelectorFromString(@"myMethod:"); //方法带有参数需要加冒号:
    //检查是否有"myMethod"这个名称的方法
    if ([obj respondsToSelector:sel]) {
        [obj performSelector:sel withObject:param]; //方法有多个参数时使用多个withObject传递参数
    }
}

iOS动态调用类方法(有返回值)

Class class = NSClassFromString(@"MyClass");
if (class) {
    id obj = [[class alloc] init];
    SEL sel = NSSelectorFromString(@"myMethod");
    //检查是否有"myMethod"这个名称的方法
    if ([obj respondsToSelector:sel]) {
        id = [obj performSelector:sel];
        //然后将id转换为类方法实际返回的数据类型。
        //假设为NSString *类型
        NSString *str = (NSString *)id;
        NSLog(@"str: %@", str);
    }
}

优点

  1. 弱化连接,因此并不会把没有的Framework也link到程序中。
  2. 不需要使用import,因为类是动态加载的,只要存在就可以加载。因此如果你的toolchain中没有某个类的头文件定义,而你确信这个类是可以用的,那么就可以用这种方法。

问题

采用这种方式,在Xcode中会报以下警告信息:

"performSelector may cause a leak because its selector is unknown"(因为performSelector的选择器未知可能会引起泄漏)

原因

在ARC下调一个方法,runtime需要知道对于返回值该怎么办。返回值可能有各种类型:voidintcharNSString *id等等。ARC 一般是根据返回值的头文件来决定该怎么办的,一共有以下4种情况:

  1. 直接忽略(如果是基本类型比如 voidint这样的)。
  2. 把返回值先retain,等到用不到的时候再release(最常见的情况)。
  3. retain,等到用不到的时候直接release(用于 initcopy 这一类的方法,或者标注ns_returns_retained的方法)。
  4. 什么也不做,默认返回值在返回前后是始终有效的(一直到最近的release pool结束为止,用于标注ns_returns_autoreleased的方法)。

而调performSelector:的时候,系统会默认返回值并不是基本类型,但也不会retainrelease,也就是默认采取第 4 种做法。所以如果那个方法本来应该属于前3种情况,都有可能会造成内存泄漏。

对于返回void或者基本类型的方法,就目前而言你可以忽略这个warning,但这样做不一定安全。我看过Clang在处理返回值这块的几次迭代演进。一旦开着ARC,编译器会觉得从performSelector:返回的对象没理由不能retain,不能release。在编译器眼里,它就是个对象。所以,如果返回值是基本类型或者void,编译器还是存在会retainrelease它的可能,然后直接导致crash。

解决办法

1. 使用宏忽略警告(不推荐)

#define SuppressPerformSelectorLeakWarning(Stuff) \
do {\
    _Pragma("clang diagnostic push") \
    _Pragma("clang diagnostic ignored \"-Warc-performSelector-leaks\"") \
    Stuff; \
    _Pragma("clang diagnostic pop") 
\} while (0)

在产生警告也就是performSelector的地方用使用该宏,如:

SuppressPerformSelectorLeakWarning([obj performSelector:sel]);

1. 使用函数指针方式(推荐)

Class class = NSClassFromString(@"MyClass");
if (class) {
    id obj = [[class alloc] init];
    SEL sel = NSSelectorFromString(@"myMethod");
    IMP imp = [obj methodForSelector:sel];
    void (*function)(id, SEL) = (void *)imp;
    function(obj, sel);
}

这一堆代码在做的事情其实是向obj请求那个方法对应的C函数指针。所有的NSObject都能响应methodForSelector:这个方法,不过也可以用Objective-C runtime里的class_getMethodImplementation(只在protocol的情况下有用,id<SomeProto>这样的)。这种函数指针叫做IMP,就是typedef过的函数指针(id (*IMP)(id, SEL, ...))。它跟方法签名(signature)比较像,虽然可能不是完全一样。

得到IMP之后,还需要进行转换,转换后的函数指针包含ARC所需的那些细节(比如每个OC方法调用都有的两个隐藏参数self_cmd)。这就是代码第6行干的事(右边的那个(void *)只是告诉编译器,不用报类型强转的warning)。

最后一步,调用函数指针。

如果selector接收参数,或者有返回值,代码就需要改改:

Class class = NSClassFromString(@"MyClass");
if (class) {
    id obj = [[class alloc] init];
    SEL sel = NSSelectorFromString(@"myMethod:");
    IMP imp = [obj methodForSelector:sel];
    NSString *(*function)(id, SEL, NSString *) = (void *)imp;
    NSString *result = function(obj, sel);
}
部分引用内容出处如下:

链接:[https://www.jianshu.com/p/a9569a9c9a63][1]
链接:[https://www.jianshu.com/p/6517ab655be7][2]

Fastlane 实践(一):自动化打包和发布

遇到问题, 多了一个扩展打包失败,顺便看到了这个文章觉得不错,转载记录一下,
xcode 12 自动打包问题,dev 有arm64 兼容问题,切换ad-hoc 自动打包上传解决

From: http://chaosky.tech/2020/05/04/fastlane-in-action-1/

fastlane is the easiest way to automate beta deployments and releases for your iOS and Android apps. 🚀 It handles all tedious tasks, like generating screenshots, dealing with code signing, and releasing your application.

fastlane 是自动化Beta部署和发布iOS和Android应用程序最简单方法。它可以处理所有繁琐的任务,例如生成屏幕截图,处理代码签名以及发布应用程序。

Fastlane 安装

安装 Xcode command line tools

$ xcode-select --install  

安装 Homebrew

$ /usr/bin/ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"  

安装 RVM

$ curl -sSL https://get.rvm.io | bash -s stable --auto-dotfiles  

$ source ~/.rvm/scripts/rvm  

修改 RVM 的 Ruby 安装源到 Ruby China 的 Ruby 镜像服务器,这样能提高安装速度。

$ echo "ruby_url=https://cache.ruby-china.org/pub/ruby" > ~/.rvm/user/db  

安装Ruby 2.6.5

$ rvm install 2.6.5  

$ rvm use 2.6.5 --default  

更新 RubyGems 镜像

$ gem sources --add https://gems.ruby-china.org/ --remove https://rubygems.org/  

$ gem sources -l  

https://gems.ruby-china.org  

# 确保只有 gems.ruby-china.org  

bundle config mirror.https://rubygems.org https://gems.ruby-china.org  

安装 CocoaPods 和 Fastlane

$ gem install cocoapods  

$ gem install fastlane -NV  

$ gem install bundle  

快速开始

  1. 进入 iOS App 的目录并运行:

    1

fastlane init  

fastlane 会自动自动识别你的项目,并询问任何缺失的信息。

  1. fastlane Getting Started guide for iOS
  1. fastlane Getting Started guide for Android

Fastlane 进阶用法

随着公司项目的增多,每次都运行重复的Fastlane 命令进行配置会低效很多,所以急需一套可以满足所有App需求的配置。

Fastlane 是由Ruby开发,所以也支持 dotenv 的功能。

最终Fastlane生成目录结构如下:

├── .env
├── Appfile
├── Deliverfile
├── Fastfile
├── Matchfile
├── Pluginfile
├── README.md
├── Scanfile
├── metadata
│ ├── app_icon.jpg
│ ├── copyright.txt
│ ├── primary_category.txt
│ ├── primary_first_sub_category.txt
│ ├── primary_second_sub_category.txt
│ ├── review_information
│ │ ├── demo_password.txt
│ │ ├── demo_user.txt
│ │ ├── email_address.txt
│ │ ├── first_name.txt
│ │ ├── last_name.txt
│ │ ├── notes.txt
│ │ └── phone_number.txt
│ ├── secondary_category.txt
│ ├── secondary_first_sub_category.txt
│ ├── secondary_second_sub_category.txt
│ ├── trade_representative_contact_information
│ │ ├── address_line1.txt
│ │ ├── address_line2.txt
│ │ ├── address_line3.txt
│ │ ├── city_name.txt
│ │ ├── country.txt
│ │ ├── email_address.txt
│ │ ├── first_name.txt
│ │ ├── is_displayed_on_app_store.txt
│ │ ├── last_name.txt
│ │ ├── phone_number.txt
│ │ ├── postal_code.txt
│ │ ├── state.txt
│ │ └── trade_name.txt
│ └── zh-Hans
│ ├── apple_tv_privacy_policy.txt
│ ├── description.txt
│ ├── keywords.txt
│ ├── marketing_url.txt
│ ├── name.txt
│ ├── privacy_url.txt
│ ├── promotional_text.txt
│ ├── release_notes.txt
│ ├── subtitle.txt
│ └── support_url.txt
└── pem
├── development_xxx.xxx.xxx.p12
├── development_xxx.xxx.xxx.pem
├── development_xxx.xxx.xxx.pkey
├── production_xxx.xxx.xxx.p12
├── production_xxx.xxx.xxx.pem
├── production_xxx.xxx.xxx.pkey

.env

这个文件中放入的是需要引用的环境变量。

FASTLANE_SKIP_UPDATE_CHECK=true  

FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT=120  



APPLE_ID="xxxx"    

TEAM_ID="xxxx"         

FASTLANE_PASSWORD="xxx"      

FASTLANE_APPLE_APPLICATION_SPECIFIC_PASSWORD="qwwe-tdpp-hdpc-fgzy"   

ITC_TEAM_ID="xxxx"       



APP_IDENTIFIER="xxx.xxx.xxx"  

SCHEME_NAME="XXX"  

WORKSPACE_NAME="XXX.xcworkspace"  

XCODEPROJ_NAME="XXX.xcodeproj"  





DEV_APP_IDENTIFIER="xxx.xxx.dev.xxx"  

DEV_APP_NAME="XXX测试版"  





PROD_APP_IDENTIFIER="xxx.xxx.xxx"  

PROD_APP_NAME="XXX"  



MATCH_GIT_BRANCH="XXX"  



DELIVER_METADATA_PATH="./fastlane/metadata"  

DOWNLOAD_METADATA_PATH="./metadata"  

Appfile

app_identifier "#{ENV["APP_IDENTIFIER"]}"   

apple_id "#{ENV["APPLE_ID"]}"   



team_id "#{ENV["TEAM_ID"]}"   

itc_team_id "#{ENV["ITC_TEAM_ID"]}"   

Deliverfile

app_identifier "#{ENV["APP_IDENTIFIER"]}"   

username "#{ENV["APPLE_ID"]}"   

Fastfile

fastlane_require "spaceship"  



fastlane_version "2.89.0"  



default_platform :ios  



platform :ios do  



  base_path = Pathname::new(File::dirname(__FILE__)).realpath.parent  



  before_all do  







  end  



  desc "生成 adhoc 测试版本,提交到蒲公英,参数 => type:'adhoc/development',默认adhoc"  

  lane :pgyer_beta do |options|  



    type = String(options[:type] || "adhoc")  



    if type == "adhoc"  

      export_method = "ad-hoc"  

      match_type = "adhoc"  

      match_type_name = "AdHoc"  

    else  

      export_method = "development"  

      match_type = "development"  

      match_type_name = "Development"  

    end  



    git_reversion = sh("git log -1 --pretty=format:'%h'")  

    version_number = get_info_plist_value(path: "#{ENV["SCHEME_NAME"]}/Info.plist", key: "CFBundleShortVersionString")  

    build_number = number_of_commits(all: false)  





    git_log = sh("git log --no-merges -1 --pretty=format:'# %ai%n# %B by %an'")  

    build_time = Time.new.strftime("%Y-%m-%d_%H.%M.%S")  





    output_dir = "#{base_path}/Output/adhoc/#{build_time}"  

    output_name = "#{ENV["SCHEME_NAME"]}_v#{version_number}(#{build_number}).ipa"  





    add_badge(shield: "#{version_number}-#{build_number}-orange")  





    increment_build_number(build_number: build_number)  





    update_app_identifier(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      plist_path: "#{ENV["SCHEME_NAME"]}/Info.plist",  

      app_identifier: "#{ENV["DEV_APP_IDENTIFIER"]}"  

    )  



    update_info_plist(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      plist_path: "#{ENV["SCHEME_NAME"]}/Info.plist",  

      block: proc do |plist|  

        plist["CFBundleDisplayName"] = "#{ENV["DEV_APP_NAME"]}"  

        plist["CFBundleName"] = "#{ENV["DEV_APP_NAME"]}"  

        plist["GIT_REVISION"] = git_reversion  

        plist["BUILD_TIME"] = build_time  

        plist["APP_CHANNEL"] = "pgyer"  

        urlScheme = plist["CFBundleURLTypes"].find{|scheme| scheme["CFBundleURLName"] == "weixin"}  

        urlScheme[:CFBundleURLSchemes] = ["#{ENV["DEV_WEIXIN_APPID"]}"]  

      end  

    )  





    update_app_identifier(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      plist_path: "#{ENV["NOTIFICATIONSERVICE_SCHEME_NAME"]}/Info.plist",  

      app_identifier: "#{ENV["DEV_NOTIFICATION_SERVICE"]}"  

    )  



    match(  

      type: "#{match_type}",   

      app_identifier: ["#{ENV["DEV_APP_IDENTIFIER"]}", "#{ENV["DEV_NOTIFICATION_SERVICE"]}"],   

      readonly: true  

    )  



    gym(  

      export_method: "#{export_method}",  

      include_bitcode: false,  

      scheme: "#{ENV["SCHEME_NAME"]}",   

      configuration: "AdHoc",  

      export_options: {  

        compileBitcode: false,  

        uploadBitcode: false,  

        provisioningProfiles: {  

          "#{ENV["DEV_APP_IDENTIFIER"]}" => "match #{match_type_name} #{ENV["DEV_APP_IDENTIFIER"]}",  

          "#{ENV["DEV_NOTIFICATION_SERVICE"]}" => "match #{match_type_name} #{ENV["DEV_NOTIFICATION_SERVICE"]}"  

        }  

      },  

      output_directory: output_dir,  

      output_name: output_name  

    )  



    upload_ipa(type: 'gxm', log: git_log)  





    bugly(app_id: "#{ENV["DEV_BUGLY_APPID"]}",  

      app_key:"#{ENV["DEV_BUGLY_APPKEY"]}",  

      symbol_type: 2,  

      bundle_id: "#{ENV["DEV_APP_IDENTIFIER"]}",  

      product_version: "#{version_number}(#{build_number})",  

      channel: 'pgyer'  

    )  



    copy_dsym(tpye: 'adhoc')  

  end  



  desc "生成 adhoc 预发版本,提交到蒲公英"  

  lane :pgyer_release do  



    git_reversion = sh("git log -1 --pretty=format:'%h'")  

    build_time = Time.new.strftime("%Y-%m-%d_%H.%M.%S")  

    version_number = get_info_plist_value(path: "#{ENV["SCHEME_NAME"]}/Info.plist", key: "CFBundleShortVersionString")  

    build_number = number_of_commits(all: false)  

    git_log = sh("git log --no-merges -1 --pretty=format:'# %ai%n# %B by %an'")  





    output_dir = "#{base_path}/Output/release/#{build_time}"  

    output_name = "#{ENV["SCHEME_NAME"]}_v#{version_number}(#{build_number}).ipa"  





    add_badge(shield: "#{version_number}-#{build_number}-orange", alpha: true)  





    increment_build_number(build_number: build_number)  



    update_app_identifier(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      plist_path: "#{ENV["SCHEME_NAME"]}/Info.plist",  

      app_identifier: "#{ENV["PROD_APP_IDENTIFIER"]}"  

    )  



    update_info_plist(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      plist_path: "#{ENV["SCHEME_NAME"]}/Info.plist",  

      block: proc do |plist|  

        plist["CFBundleDisplayName"] = "#{ENV["PROD_APP_NAME"]}"  

        plist["CFBundleName"] = "#{ENV["PROD_APP_NAME"]}"  

        plist["GIT_REVISION"] = git_reversion  

        plist["BUILD_TIME"] = build_time  

        plist["APP_CHANNEL"] = "pgyer"  

        urlScheme = plist["CFBundleURLTypes"].find{|scheme| scheme["CFBundleURLName"] == "weixin"}  

        urlScheme[:CFBundleURLSchemes] = ["#{ENV["PROD_WEIXIN_APPID"]}"]  

      end  

    )  





    update_app_identifier(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      plist_path: "#{ENV["NOTIFICATIONSERVICE_SCHEME_NAME"]}/Info.plist",  

      app_identifier: "#{ENV["PROD_NOTIFICATION_SERVICE"]}"  

    )  



    match(  

      type: "adhoc",   

      app_identifier: ["#{ENV["PROD_APP_IDENTIFIER"]}", "#{ENV["PROD_NOTIFICATION_SERVICE"]}"],   

      readonly: true  

    )  



    update_project_provisioning(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      target_filter: "#{ENV["SCHEME_NAME"]}",  

      profile:ENV["sigh_#{ENV["PROD_APP_IDENTIFIER"]}_adhoc_profile-path"],  

      build_configuration: "Release"  

    )  



    update_project_provisioning(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      target_filter: "#{ENV["NOTIFICATIONSERVICE_SCHEME_NAME"]}",  

      profile:ENV["sigh_#{ENV["PROD_NOTIFICATION_SERVICE"]}_adhoc_profile-path"],  

      build_configuration: "Release"  

    )  



    gym(  

      export_method: "ad-hoc",   

      scheme: "#{ENV["SCHEME_NAME"]}",   

      configuration: "Release",  

      export_options: {  

        compileBitcode: false,  

        uploadBitcode: false,  

        provisioningProfiles: {  

          "#{ENV["PROD_APP_IDENTIFIER"]}" => "match AdHoc #{ENV["PROD_APP_IDENTIFIER"]}",  

          "#{ENV["PROD_NOTIFICATION_SERVICE"]}" => "match AdHoc #{ENV["PROD_NOTIFICATION_SERVICE"]}"  

        }  

      },  

      output_directory: output_dir,  

      output_name: output_name  

    )  







    upload_ipa(type: 'gxm', log: "App Store 包上传:#{version_number}(#{build_number})")  





    bugly(app_id: "#{ENV["PROD_BUGLY_APPID"]}",  

      app_key:"#{ENV["PROD_BUGLY_APPKEY"]}",  

      symbol_type: 2,  

      bundle_id: "#{ENV["PROD_APP_IDENTIFIER"]}",  

      product_version: "#{version_number}(#{build_number})",  

      channel: 'pgyer'  

    )  



    copy_dsym(tpye: 'release')  

  end  



  desc "生成 appstore 版本,发布到 App Store"  

  lane :appstore_release do  



    git_reversion = sh("git log -1 --pretty=format:'%h'")  

    build_time = Time.new.strftime("%Y-%m-%d_%H.%M.%S")  

    version_number = get_info_plist_value(path: "#{ENV["SCHEME_NAME"]}/Info.plist", key: "CFBundleShortVersionString")  

    build_number = number_of_commits(all: false)  





    output_dir = "#{base_path}/Output/appstore/#{build_time}"  

    output_name = "#{ENV["SCHEME_NAME"]}_v#{version_number}(#{build_number}).ipa"  



    clear_derived_data  





    increment_build_number(build_number: build_number)  



    update_app_identifier(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      plist_path: "#{ENV["SCHEME_NAME"]}/Info.plist",  

      app_identifier: "#{ENV["PROD_APP_IDENTIFIER"]}"  

    )  



    update_info_plist(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      plist_path: "#{ENV["SCHEME_NAME"]}/Info.plist",  

      block: proc do |plist|  

        plist["CFBundleDisplayName"] = "#{ENV["PROD_APP_NAME"]}"  

        plist["CFBundleName"] = "#{ENV["PROD_APP_NAME"]}"  

        plist["GIT_REVISION"] = git_reversion  

        plist["BUILD_TIME"] = build_time  

        plist["APP_CHANNEL"] = "appstore"  

        urlScheme = plist["CFBundleURLTypes"].find{|scheme| scheme["CFBundleURLName"] == "weixin"}  

        urlScheme[:CFBundleURLSchemes] = ["#{ENV["PROD_WEIXIN_APPID"]}"]  

      end  

    )  





    update_app_identifier(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      plist_path: "#{ENV["NOTIFICATIONSERVICE_SCHEME_NAME"]}/Info.plist",  

      app_identifier: "#{ENV["PROD_NOTIFICATION_SERVICE"]}"  

    )  



    match(  

      type: "appstore",   

      app_identifier: ["#{ENV["PROD_APP_IDENTIFIER"]}", "#{ENV["PROD_NOTIFICATION_SERVICE"]}"],   

      readonly: true  

    )  



    update_project_provisioning(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      target_filter: "#{ENV["SCHEME_NAME"]}",  

      profile:ENV["sigh_#{ENV["PROD_APP_IDENTIFIER"]}_appstore_profile-path"],  

      build_configuration: "AppStore"  

    )  



    update_project_provisioning(  

      xcodeproj: "#{ENV["XCODEPROJ_NAME"]}",  

      target_filter: "#{ENV["NOTIFICATIONSERVICE_SCHEME_NAME"]}",  

      profile:ENV["sigh_#{ENV["PROD_NOTIFICATION_SERVICE"]}_appstore_profile-path"],  

      build_configuration: "AppStore"  

    )  







    gym(  

      export_method: "app-store",   

      scheme: "#{ENV["SCHEME_NAME"]}",   

      configuration: "AppStore",  

      export_options: {  

        provisioningProfiles: {  

          "#{ENV["PROD_APP_IDENTIFIER"]}" => "match AppStore #{ENV["PROD_APP_IDENTIFIER"]}",  

          "#{ENV["PROD_NOTIFICATION_SERVICE"]}" => "match AppStore #{ENV["PROD_NOTIFICATION_SERVICE"]}"  

        }  

      },  

      output_directory: output_dir,  

      output_name: output_name  

    )  





    bugly(app_id: "#{ENV["PROD_BUGLY_APPID"]}",  

      app_key:"#{ENV["PROD_BUGLY_APPKEY"]}",  

      symbol_type: 2,  

      bundle_id: "#{ENV["PROD_APP_IDENTIFIER"]}",  

      product_version: "#{version_number}(#{build_number})",  

      channel: 'appstore'  

    )  





    upload_ipa(type: 'gxm', log: "App Store 包上传:#{version_number}(#{build_number})")  



    copy_dsym(type: 'appstore')  



    deliver(  

      metadata_path: "#{ENV["DELIVER_METADATA_PATH"]}",  

      force: true  

    )  



  end  



  desc "上传 AppStore DSYM 文件到 Bugly,参数 => version:[latest]"  

  lane :upload_appstore_dsyms do |options|  

    version = String(options[:version] || "latest")  

    download_dsyms(version: version)  

    dsym_paths = lane_context[SharedValues::DSYM_PATHS]  

    for dsym_path in dsym_paths  



      split_strs = dsym_path.split(/\//).last.split(/-/)  

      version_number = split_strs[1]  

      build_number = split_strs[2].split(/\./)[0]  



      bugly(app_id: "#{ENV["PROD_BUGLY_APPID"]}",  

        app_key:"#{ENV["PROD_BUGLY_APPKEY"]}",  

        symbol_type: 2,  

        bundle_id: "#{ENV["PROD_APP_IDENTIFIER"]}",  

        product_version: "#{version_number}(#{build_number})",  

        channel: 'appstore',  

        dsym: dsym_path  

      )  

    end  

    clean_build_artifacts  

  end  



  desc "手动批量添加设备到profile"  

  lane :add_devices_manual do  



    UI.header "Add Device"  

    device_hash = {}  

    device_sum = UI.input("Device Sum: ").to_i  

    if device_sum == 0  

      next  

    end  

    index = 0  

    while index < device_sum do  

      device_name = UI.input("Device Name: ")  

      device_udid = UI.input("Device UDID: ")  

      device_hash[device_name] = device_udid  

      index += 1  

    end  



    register_devices(  

        devices: device_hash  

    )  

    refresh_profiles  

  end  



  desc "文件批量添加设备到profile"  

  lane :add_devices_file do  

    register_devices(  

      devices_file: "fastlane/devices.txt"  

    )  

    refresh_profiles  

  end  



  desc "批量导出设备"  

  lane :export_devices do  

    password = UI.password("输入 #{ENV["APPLE_ID"]} 账号密码: ")  

    Spaceship::Portal.login("#{ENV["APPLE_ID"]}", password)  

    Spaceship::Portal.select_team(team_id: "#{ENV["TEAM_ID"]}")  

    devices = Spaceship.device.all  

    File.open("#{base_path}/fastlane/devices.txt", "wb") do |f|  

      f.puts "Device ID\tDevice Name"  

      devices.each do |device|  

        f.puts "#{device.udid}\t#{device.name}"  

      end  

    end  

  end  





  desc "更新 provisioning profiles"  

  lane :refresh_profiles do  

    match(  

      type: "development",  

      force: true,  

      force_for_new_devices: true  

    )  

    match(  

      type: "adhoc",  

      force: true,  

      force_for_new_devices: true  

    )  

    match(  

      type: "appstore",  

      force: true,  

      force_for_new_devices: true  

    )  

  end  



  desc "同步 certificates 和 provisioning profiles"  

  lane :sync_cert_profiles do  

    match(  

      type: "development",  

      readonly: true  

    )  

    match(  

      type: "adhoc",  

      readonly: true  

    )  

    match(  

      type: "appstore",  

      readonly: true  

    )  

  end  



  desc "移除本地描述文件"  

  lane :remove_local_profiles do  

    app_identifiers = ["#{ENV["DEV_APP_IDENTIFIER"]}", "#{ENV["DEV_NOTIFICATION_SERVICE"]}", "#{ENV["PROD_APP_IDENTIFIER"]}", "#{ENV["PROD_NOTIFICATION_SERVICE"]}"]  

    types = ["development", "adhoc", "appstore"]  

    app_identifiers.each do |app_identifier|  

      types.each do |type|  

        remove_provisioning_profile(app_identifier: app_identifier, type: type)      

      end  

    end  

  end  



  desc "revoke 证书和描述文件"  

  private_lane :revoke_cert_profiles do  

    ENV["MATCH_SKIP_CONFIRMATION"] = "1"  

    sh("fastlane match nuke development")  

    sh("fastlane match nuke distribution")  

  end  



  desc "生成APNs证书"  

  lane :generate_apns_cert do  

    pem(  

      development: true,   

      force: true,   

      app_identifier: "#{ENV["DEV_APP_IDENTIFIER"]}",   

      p12_password: "GXM", output_path: "fastlane/pem"  

    )  



    pem(  

      development: false,   

      force: true,   

      app_identifier: "#{ENV["DEV_APP_IDENTIFIER"]}",   

      p12_password: "GXM", output_path: "fastlane/pem"  

    )  



    pem(  

      development: true,   

      force: true,   

      app_identifier: "#{ENV["PROD_APP_IDENTIFIER"]}",   

      p12_password: "GXM", output_path: "fastlane/pem"  

    )  



    pem(  

      development: false,   

      force: true,   

      app_identifier: "#{ENV["PROD_APP_IDENTIFIER"]}",   

      p12_password: "GXM", output_path: "fastlane/pem"  

    )  

  end  



  desc "同步 metadata"  

  lane :sync_metadata do  

    ENV["DELIVER_FORCE_OVERWRITE"] = "1"  

    sh("fastlane deliver download_metadata --metadata_path #{ENV["DOWNLOAD_METADATA_PATH"]}")  

  end  



  desc "拷贝 dSYM"  

  private_lane :copy_dsym do |options|  

    type = String(options[:type] || "adhoc")  

    dsym_path = lane_context[SharedValues::DSYM_OUTPUT_PATH]  

    share_dir = File.join(ENV['HOME'],'/Public/iOS', "#{ENV["SCHEME_NAME"]}", "#{type}")  

    FileUtils.mkdir_p(share_dir)  

    FileUtils.cp_r(File.join(dsym_path), share_dir)  

  end  



  desc "上传 ipa,type: [pgyer,gxm], log: desc"  

  private_lane :upload_ipa do |options|  

    type = options[:type] || 'pgyer'  

    log = options[:log] || ''  

    log = String  

    if type == "pgyer"  

      pgyer(  

        api_key: '0098b94391ff417d86837343597789a9',  

        user_key: '4ca1278171177f624ba3f3cc39eb2d73',  

        update_description: log  

      )  

    else  

      sh("curl -X 'POST' 'https://fabu.guoxiaomei.com/api/apps/5dca5121f3920d001f71e42d/upload' -H 'Content-Type: multipart/form-data' -H 'accept: application/json' -H 'apikey: 07a0840834294e7b89c41ab9c302c852' -F 'file=@#{lane_context[SharedValues::IPA_OUTPUT_PATH]}'")  

    end  

  end  



  after_all do |lane|  











  end  



  error do |lane, exception|  









  end  

end  

以上 fastlane 满足基本的功能需求。

iOS应用架构谈 组件化方案2

#
在现有工程中实施基于CTMediator的组件化方案

From: https://casatwy.com/modulization_in_action.html

国内业界大家对组件化的讨论从今年年初开始到年尾,不外乎两个方案:URL/protocol注册调度,runtime调度。

我之前批评过URL注册调度是错误的组件化实施方案,在所有的基于URL注册调度的方案中,存在两个普遍问题:

  1. 命名域渗透
  2. 因注册是不必要的,而带来同样不必要的注册列表维护成本

其它各家的基于URL注册的不同方案在这两个普遍问题上还有各种各样的其他问题,例如FRDIntent库中的FRDIntent对象其本质是鸡肋对象、原属于响应者的业务被渗透到调用者的业务中、组件化实施方案的过程中会产生对原有代码的侵入式修改等问题。

另外,我也发现还是有人在都没有理解清楚的前提下就做出了自己的解读,流毒甚广。我之前写过关于CTMediator比较理论的描述,也有Demo,但惟独没有写实践方面的描述。我本来以为Demo就足够了,可现在看来还是要给一篇实践的文章的。

在更早之前,卓同学的swift老司机群里也有人提出因为自己并没有理解透彻CTMediator方案,所以不敢贸然直接在项目中应用。所以这篇文章的另一个目的也是希望能够让大家明白,基于CTMediator的组件化方案实施其实非常简单,而且也是有章法可循的。这篇文章可能会去讨论一些理论的东西,但主要还会是以实践为主。争取做到能够让大家看完文章之后就可以直接在自己的项目中顺利实施组件化。

最后,我希望这篇文章能够终结业界持续近一年的关于组件化方案的无谓讨论和错误讨论。

我在github上开了一个orgnization,里面有一个主工程:MainProject,我们要针对这个工程来做组件化。组件化实施完毕之后的主工程就是ModulizedMainProject了。抽出来的独立Pod、私有Pod源也都会放在这个orgnization中去。

在一个项目实施组件化方案之前,我们需要做一个准备工作,建立自己的私有Pod源和快手工具脚本的配置:

  1. 先去开一个repo,这个repo就是我们私有Pod源仓库
  2. pod repo add [私有Pod源仓库名字] [私有Pod源的repo地址]
  3. 创立一个文件夹,例如Project。把我们的主工程文件夹放到Project下:~/Project/MainProject
  4. 在~/Project下clone快速配置私有源的脚本repo:git clone git@github.com:casatwy/ConfigPrivatePod.git
  5. 将ConfigPrivatePod的template文件夹下Podfile中source 'https://github.com/ModulizationDemo/PrivatePods.git'改成第一步里面你自己的私有Pod源仓库的repo地址
  6. 将ConfigPrivatePod的template文件夹下upload.sh中PrivatePods改成第二步里面你自己的私有Pod源仓库的名字

最后你的文件目录结构应该是这样:

Project
├── ConfigPrivatePod
└── MainProject

到此为止,准备工作就做好了。

MainProject是一个非常简单的应用,一共就三个页面。首页push了AViewController,AViewController里又push了BViewController。我们可以理解成这个工程由三个业务组成:首页、A业务、B业务。

我们这一次组件化的实施目标就是把A业务组件化出来,首页和B业务都还放在主工程。

因为在实际情况中,组件化是需要循序渐进地实施的。尤其是一些已经比较成熟的项目,业务会非常多,一时半会儿是不可能完全组件化的。CTMediator方案在实施过程中,对主工程业务的影响程度极小,而且是能够支持循序渐进地改造方式的。这个我会在文章结尾做总结的时候提到。

既然要把A业务抽出来作为组件,那么我们需要为此做两个私有Pod:A业务Pod(以后简称A Pod)、方便其他人调用A业务的CTMediator category的Pod(以后简称A_Category Pod)。这里多解释一句:A_Category Pod本质上只是一个方便方法,它对A Pod不存在任何依赖。

我们先创建A Pod

  1. 新建Xcode工程,命名为A,放到Projects下
  2. 新建Repo,命名也为A,新建好了之后网页不要关掉

此时你的文件目录结构应该是这样:

Project
├── ConfigPrivatePod
├── MainProject
└── A

然后cd到ConfigPrivatePod下,执行./config.sh脚本来配置A这个私有Pod。脚本会问你要一些信息,Project Name就是A,要跟你的A工程的目录名一致。HTTPS RepoSSH Repo网页上都有,Home Page URL就填你A Repo网页的URL就好了。

这个脚本是我写来方便配置私有库的脚本,pod lib create也可以用,但是它会直接从github上拉一个完整的模版工程下来,只是国内访问github其实会比较慢,会影响效率。而且这个配置工作其实也不复杂,我就索性自己写了个脚本。

这个脚本要求私有Pod的文件目录要跟脚本所在目录平级,也会在XCode工程的代码目录下新建一个跟项目同名的目录。放在这个目录下的代码就会随着Pod的发版而发出去,这个目录以外的代码就不会跟随Pod的版本发布而发布,这样子写用于测试的代码就比较方便。

然后我们在主工程中,把属于A业务的代码拎出来,放到新建好的A工程的A文件夹里去,然后拖放到A工程中。原来主工程里面A业务的代码直接删掉,此时主工程和A工程编译不过都是正常的,我们会在第二步中解决主工程的编译问题,第三步中解决A工程的编译问题。

此时你的主工程应该就没有A业务的代码了,然后你的A工程应该是这样:

A
├── A
|   ├── A
|   │   ├── AViewController.h
|   │   └── AViewController.m
|   ├── AppDelegate.h
|   ├── AppDelegate.m
|   ├── ViewController.h
|   ├── ViewController.m
|   └── main.m
└── A.xcodeproj

我们再创建A_Category Pod

同样的,我们再创建A_Category,因为它也是个私有Pod,所以也照样子跑一下config.sh脚本去配置一下就好了。最后你的目录结构应该是这样的:

Project
├── A
│   ├── A
│   │   ├── A
│   │   ├── AppDelegate.h
│   │   ├── AppDelegate.m
│   │   ├── Assets.xcassets
│   │   ├── Info.plist
│   │   ├── ViewController.h
│   │   ├── ViewController.m
│   │   └── main.m
│   ├── A.podspec
│   ├── A.xcodeproj
│   ├── FILE_LICENSE
│   ├── Podfile
│   ├── readme.md
│   └── upload.sh
├── A_Category
│   ├── A_Category
│   │   ├── A_Category
│   │   ├── AppDelegate.h
│   │   ├── AppDelegate.m
│   │   ├── Info.plist
│   │   ├── ViewController.h
│   │   ├── ViewController.m
│   │   └── main.m
│   ├── A_Category.podspec
│   ├── A_Category.xcodeproj
│   ├── FILE_LICENSE
│   ├── Podfile
│   ├── readme.md
│   └── upload.sh
├── ConfigPrivatePod
│   ├── config.sh
│   └── templates
└── MainProject
    ├── FILE_LICENSE
    ├── MainProject
    ├── MainProject.xcodeproj
    ├── MainProject.xcworkspace
    ├── Podfile
    ├── Podfile.lock
    ├── Pods
    └── readme.md

然后去A_Category下,在Podfile中添加一行pod "CTMediator",在podspec文件的后面添加s.dependency "CTMediator",然后执行pod install --verbose

接下来打开A_Category.xcworkspace,把脚本生成的名为A_Category的空目录拖放到Xcode对应的位置下,然后在这里新建基于CTMediator的Category:CTMediator+A。最后你的A_Category工程应该是这样的:

A_Category
├── A_Category
|   ├── A_Category
|   │   ├── CTMediator+A.h
|   │   └── CTMediator+A.m
|   ├── AppDelegate.h
|   ├── AppDelegate.m
|   ├── ViewController.h
|   └── ViewController.m
└── A_Category.xcodeproj

到这里为止,A工程和A_Category工程就准备好了。

去主工程的Podfile下添加pod "A_Category", :path => "../A_Category"来本地引用A_Category。

然后编译一下,说找不到AViewController的头文件。此时我们把头文件引用改成#import <A_Category/CTMediator+A.h>

然后继续编译,说找不到AViewController这个类型。看一下这里是使用了AViewController的地方,于是我们在Development Pods下找到CTMediator+A.h,在里面添加一个方法:

- (UIViewController *)A_aViewController;

再去CTMediator+A.m中,补上这个方法的实现,把主工程中调用的语句作为注释放进去,将来写Target-Action要用:

- (UIViewController *)A_aViewController
{
    /*
        AViewController *viewController = [[AViewController alloc] init];
     */
    return [self performTarget:@"A" action:@"viewController" params:nil shouldCacheTarget:NO];
}

补充说明一下,performTarget:@"A"中给到的@"A"其实是Target对象的名字。一般来说,一个业务Pod只需要有一个Target就够了,但一个Target下可以有很多个Action。Action的名字也是可以随意命名的,只要到时候Target对象中能够给到对应的Action就可以了。

关于Target-Action我们会在第三步中去实现,现在不实现Target-Action是不影响主工程编译的。

category里面这么写就已经结束了,后面的实施过程中就不会再改动到它了。

然后我们把主工程调用AViewController的地方改为基于CTMediator Category的实现:

UIViewController *viewController = [[CTMediator sharedInstance] A_aViewController];
[self.navigationController pushViewController:viewController animated:YES];

再编译一下,编译通过。

到此为止主工程就改完了,现在跑主工程点击这个按钮跳不到A页面是正常的,因为我们还没有在A工程中实现Target-Action。

而且此时主工程中关于A业务的改动就全部结束了,后面的组件化实施过程中,就不会再有针对A业务线对主工程的改动了。

此时我们关掉所有XCode窗口。然后打开两个工程:A_Category工程和A工程。

我们在A工程中创建一个文件夹:Targets,然后看到A_Category里面有performTarget:@"A",所以我们新建一个对象,叫做Target_A

然后又看到对应的Action是viewController,于是在Target_A中新建一个方法:Action_viewController。这个Target对象是这样的:

头文件:
#import <UIKit/UIKit.h>

@interface Target_A : NSObject

- (UIViewController *)Action_viewController:(NSDictionary *)params;

@end

实现文件:
#import "Target_A.h"
#import "AViewController.h"

@implementation Target_A

- (UIViewController *)Action_viewController:(NSDictionary *)params
{
    AViewController *viewController = [[AViewController alloc] init];
    return viewController;
}

@end

这里写实现文件的时候,对照着之前在A_Category里面的注释去写就可以了。

因为Target对象处于A的命名域中,所以Target对象中可以随意import A业务线中的任何头文件。

另外补充一点,Target对象的Action设计出来也不是仅仅用于返回ViewController实例的,它可以用来执行各种属于业务线本身的任务。例如上传文件,转码等等各种任务其实都可以作为一个Action来给外部调用,Action完成这些任务的时候,业务逻辑是可以写在Action方法里面的。

换个角度说就是:Action具备调度业务线提供的任何对象和方法来完成自己的任务的能力。它的本质就是对外业务的一层服务化封装。

现在我们这个Action要完成的任务只是实例化一个ViewController并返回出去而已,根据上面的描述,Action可以完成的任务其实可以更加复杂。

然后我们再继续编译A工程,发现找不到BViewController。由于我们这次组件化实施的目的仅仅是将A业务线抽出来,BViewController是属于B业务线的,所以我们没必要把B业务也从主工程里面抽出来。但为了能够让A工程编译通过,我们需要提供一个B_Category来使得A工程可以调度到B,同时也能够编译通过。

B_Category的创建步骤跟A_Category是一样的,不外乎就是这几步:新建Xcode工程、网页新建Repo、跑脚本配置Repo、添加Category代码。

B_Category添加好后,我们同样在A工程的Podfile中本地指过去,然后跟在主工程的时候一样。

所以B_Category是这样的:

头文件:
#import <CTMediator/CTMediator.h>
#import <UIKit/UIKit.h>

@interface CTMediator (B)

- (UIViewController *)B_viewControllerWithContentText:(NSString *)contentText;

@end

实现文件:
#import "CTMediator+B.h"

@implementation CTMediator (B)

- (UIViewController *)B_viewControllerWithContentText:(NSString *)contentText
{
    /*
        BViewController *viewController = [[BViewController alloc] initWithContentText:@"hello, world!"];
     */
    NSMutableDictionary *params = [[NSMutableDictionary alloc] init];
    params[@"contentText"] = contentText;
    return [self performTarget:@"B" action:@"viewController" params:params shouldCacheTarget:NO];
}

@end

然后我们对应地在A工程中修改头文件引用为#import <B_Category/CTMediator+B.h>,并且把调用的代码改为:

UIViewController *viewController = [[CTMediator sharedInstance] B_viewControllerWithContentText:@"hello, world!"];
[self.navigationController pushViewController:viewController animated:YES];

此时再编译一下,编译通过了。注意哦,这里A业务线跟B业务线就已经完全解耦了,跟主工程就也已经完全解耦了。

此时还有一个收尾工作是我们给B业务线创建了Category,但没有创建Target-Action。所以我们要去主工程创建一个B业务线的Target-Action。创建的时候其实完全不需要动到B业务线的代码,只需要新增Target_B对象即可:

Target_B头文件:
#import <UIKit/UIKit.h>

@interface Target_B : NSObject

- (UIViewController *)Action_viewController:(NSDictionary *)params;

@end

Target_B实现文件:
#import "Target_B.h"
#import "BViewController.h"

@implementation Target_B

- (UIViewController *)Action_viewController:(NSDictionary *)params
{
    NSString *contentText = params[@"contentText"];
    BViewController *viewController = [[BViewController alloc] initWithContentText:contentText];
    return viewController;
}

@end

这个Target对象在主工程内不存在任何侵入性,将来如果B要独立成一个组件的话,把这个Target对象带上就可以了。

收尾工作就到此结束,我们创建了三个私有Pod:A、A_Category、B_Category。

接下来我们要做的事情就是给这三个私有Pod发版,发版之前去podspec里面确认一下版本号和dependency。

Category的dependency是不需要填写对应的业务线的,它应该是只依赖一个CTMediator就可以了。其它业务线的dependency也是不需要依赖业务线的,只需要依赖业务线的Category。例如A业务线只需要依赖B_Category,而不需要依赖B业务线或主工程。

发版过程就是几行命令:

git add .
git commit -m "版本号"
git tag 版本号
git push origin master --tags
./upload.sh

命令行cd进入到对应的项目中,然后执行以上命令就可以了。

要注意的是,这里的版本号要和podspec文件中的s.version给到的版本号一致。upload.sh是配置私有Pod的脚本生成的,如果你这边没有upload.sh这个文件,说明这个私有Pod你还没用脚本配置过。

最后,所有的Pod发完版之后,我们再把Podfile里原来的本地引用改回正常引用,也就是把:path...那一段从Podfile里面去掉就好了,改动之后记得commit并push。

组件化实施就这么三步,到此结束。

hard code

这个组件化方案的hard code仅存在于Target对象和Category方法中,影响面极小,并不会泄漏到主工程的业务代码中,也不会泄漏到业务线的业务代码中。

而且在实际组件化的实施中,也是依据category去做业务线的组件化的。所以先写category里的target名字,action名字,param参数,到后面在业务线组件中创建Target的时候,照着category里面已经写好的内容直接copy到Target对象中就肯定不会出错(仅Target对象,并不会牵扯到业务线本身原有的对象)。

如果要消除这一层hard code,那么势必就要引入一个第三方pod,然后target对象所在的业务线和category都要依赖这个pod。为了消除这种影响面极小的hard code,而且只要按照章法来就不会出错。为此引入一个新的依赖,其实是不划算的。

命名域问题

在这个实践中,响应者的命名域并没有泄漏到除了响应者以外的任何地方,这就带来一个好处,迁移非常方便。

比如我们的响应者是一个上传组件。这个上传组件如果要替换的话,只需要在它外面包一个Target-Action,就可以直接拿来用了。而且包Target-Action的过程中,不会产生任何侵入性的影响。

例如原来是你自己基于AFNetworking写的上传组件,现在用了七牛SDK上传,那么整个过程你只需要提供一个Target-Action封装一下七牛的上传操作即可。不需要改动七牛SDK的代码,也不需要改动调用方的代码。倘若是基于URL注册的调度,做这个事情就很蛋疼。

服务管理问题

由于Target对象处于响应者的命名域中,Target对象就可以对外提供除了页面实例以外的各种Action。

而且,由于其本质就是针对响应者对外业务逻辑的Action化封装(其实就是服务化封装),这就能够使得一个响应者对外提供了哪些Action(服务)Action(服务)的实现逻辑是什么得到了非常好的管理,能够大大降低将来工程的维护成本。然后Category解决了服务应该怎么调用的问题。

但在基于URL注册机制和Protocol共享机制的组件化方案中,由于服务散落在响应者各处,服务管理就显得十分困难。如果还是执念于这样的方案,大家只要拿上面提到的三个问题,对照着URL注册机制和Protocol共享机制的组件化方案比对一下,就能明白了。

另外,如果这种方案把所有的服务归拢到一个对象中来达到方便管理的目的的话,其本质就已经变成了Target-Action模式,Protocol共享机制其实就已经没有存在意义了。

高内聚

基于protocol共享机制的组件化方案导致响应者业务逻辑泄漏到了调用者业务逻辑中,并没有做到高内聚

如果这部分业务在其他地方也要使用,那么代码就要重新写一遍。虽然它可以提供一个业务高内聚的对象来符合这个protocol,但事实上这就又变成了Target-Action模式,protocol的存在意义就也没有了。

侵入性问题

正如你所见,CTMediator组件化方案的实施非常安全。因为它并不存在任何侵入性的代码修改。

对于响应者来说,什么代码都不用改,只需要包一层Target-Action即可。例如本例中的B业务线作为A业务的响应者时,不需要修改B业务的任何代码。

对于调用者来说,只需要把调用方式换成CTMediator调用即可,其改动也不涉及原有的业务逻辑,所以是十分安全的。

另外一个非侵入性的特征体现在,基于CTMediator的组件化方案是可以循序渐进地实施的。这个方案的实施并不要求所有业务线都要被独立出来成为组件,实施过程也并不会修改未组件化的业务的代码。

在独立A业务线的过程中如果涉及其它业务线(B业务线)的调用,就只需要给到Target对象即可,Target对象本身并不会对未组件化的业务线(B业务线)产生任何的修改。而且将来如果对应业务线需要被独立出去的时候,也仅需要把Target对象一起复制过去就可以了。

但在基于URL注册和protocol共享的组件化方案中,都必须要在未组件化的业务线中写入注册代码和protocol声明,并分配对应的URL和protocol到具体的业务对象上。这些其实都是不必要的,无端多出了额外维护成本。

注册问题

CTMediator没有任何注册逻辑的代码,避免了注册文件的维护和管理。Category给到的方法很明确地告知了调用者应该如何调用。

例如B_Category给到的- (UIViewController *)B_viewControllerWithContentText:(NSString *)contentText;方法。这能够让工程师一眼就能够明白使用方式,而不必抓瞎拿着URL再去翻文档。

这可以很大程度提高工作效率,同时降低维护成本。

实施组件化方案的时机

MVP阶段过后,越早实施越好。

这里说的MVP不是一种设计模式,而是最小价值产品的意思,它是产品演进的第一个阶段。

一般来说天使轮就是用于MVP验证的,在这个阶段产品闭环尚未确定,因此产品本身的逻辑就会各种变化。但是过了天使轮之后,产品闭环已经确定,此时就应当实施组件化,以应对A轮之后的产品拓张。

有的人说我现在项目很小,人也很少,所以没必要实施组件化。确实,把一个小项目组件化之后,跟之前相比并没有多大程度的改善,因为本来小项目就不复杂,改成组件化之后,也不会更简单。

但这其实是一种很短视的认知。

组件化对于一个小项目而言,真正发挥优势的地方是在未来的半年甚至一年之后。

因为趁着人少项目小,实施组件化的成本就也很小,三四天就可以实施完毕。于是等将来一年之后业务拓张到更大规模时,就不会束手束脚了。

但如果等到项目大了,人手多了再去实施组件化,那时候实施组件化的复杂度肯定比现在规模还很小的时候的复杂度要大得多,三四天肯定搞不定,而且实施过程还会非常艰辛。到那时你就后悔为什么当初没有早早实施组件化了。

Swift工程怎么办?

其实只要Target对象继承自NSObject就好了,然后带上@objc(className)。action的参数名永远只有一个,且名字需要固定为params,其它照旧。具体swift工程中target的写法参见A_swift

因为Target对象是游离于业务实现的,所以它去继承NSObject完全没有任何问题。完整的SwiftDemo在这里。


本文Demo