0%

构建更灵活的生产流程(上)

写在最前面:Session 比较适合于有一整条明显链路关系的页面进行串连,不适合应用于所有的场景

本文阐述了 Session 如何实现页面跳转灵活性、标准化数据流、更精准的生命周期,以及重构过程中遇到的问题以及其中的思考

一、Session 的背景

之前有同乡,广告,在线课程等业务方希望利用生产的某一部分功能,并且对某些部分做定制化的要求,之前的生产流程都是固定化的,要适应诸如此类的需求都是一个 case 一个 case 处理,非常繁琐也对未来应对同样类型的需求没有任何裨益。希望能有一个更标准化的生产流程,更容易的对接诸如此类的需求。

19年开始做这个事情以后就开始在业务开发过程中去不断探索,最终迭代了三个版本现在终于有一个比较稳定可用的解决方案应对业务方提出的需求。

对于 session 的目的大致分为两个方向:

1. 对业务方

1、他们能够灵活得对自己的业务进行传参,不再是每次修改都必须修改主站主路径各个页面的接口来传递主流程根本不关心的参数,让代码更好维护。

2、对整个流程进行抽象后,页面的跳转逻辑不再由页面自己负责,而是把职责交付给了session,生产对于自己的功能更专注了,同时业务方也能更灵活定制自己想要的跳转链路

1.2 对生产的开发

1、session 提供了一个链路级别的抽象概念,在这个流程中 session 像是一个单例的存在。依赖这个特性可以非常方便实现内部数据的复用还有页面间的数据通讯逻辑

2、代码职责划分也变得更加清晰了,每个页面之关心自己接受什么样子的输入并且按照接口协议给到对应的输出即可,可以把各种关于业务的 case by case 判断给剥离出去

3、数据标准化后,我们甚至能够实现页面级别的直接替换。输入参数统一以后,其他入口调用就再也不用写 if else 的跳转考虑到底应该跳哪个页面,session 负责

在介绍 session 之前,先介绍一下现有代码的问题。

二、MVC 的陷阱

作为 iOS 开发,MVC 是被使用最广泛的模式。这里有两个陷阱:

  1. 苹果的很多示例代码为了精简代码量,有着错误的示范,写出了一些在违反封装性的代码。
  2. 没有规范,导致了臃肿的 MVC。一个视图控制器四五千行代码量非常常见。

2.1 违反封装性

UITableViewDelegate 是苹果示例代码里面最容易表现这一点的,看看下面这段非常简单熟悉的代码。从数据源中获取点击的 index 对应的数据,使用该数据初始化对应控制器并且将它进行展示。

1
2
3
4
5
- (void)tableView:(UITableView *)tableView didSelectRowAtIndexPath:(NSIndexPath *)indexPath {
id item = self.items[indexPath.row];
MViewController *vc = [[MViewController alloc] initWithItem:item];
[self.navigationController pushViewController:vc animated:YES];
}

wiki 对于封装的定义:

In object-oriented programming (OOP), encapsulation refers to the bundling of data with the methods that operate on that data, or the restricting of direct access to some of an object’s components.[1] Encapsulation is used to hide the values or state of a structured data object inside a class, preventing unauthorized parties’ direct access to them. Publicly accessible methods are generally provided in the class (so-called “getters” and “setters”) to access the values, and other client classes call these methods to retrieve and modify the values within the object.

在面向对象中,封装指代将数据的直接引用改为了通过方法操作,或者对某些对象内部组件访问进行限制。封装被用来防止未授权的类直接访问隐藏属性或者类内部的状态。公开访问权限的方法一般被用来访问属性(比如 setter & getter),其他类可以通过暴露的公共方法实现修改值的目的

封装性:一个类只能知道自己持有的对象,并且只能属于抽象结构中的一层。

直接在当前页面明确了自己要构造的控制器

1
MViewController *vc = [[MViewController alloc] initWithItem:item];

因为这行代码构造了下一级页面,意味着当前控制器必须知道在数据流处理的次序逻辑才是正确的。

一方面这个控制器职责增加了(既要负责数据点击的响应又要控制页面的创建以及跳转),为了保障页面跳转正确意味着这个控制器必须假设自己知道所在的上下文关系(通常是自己的上一级、上上级页面是谁)。

明确了跳转流程

1
[self.navigationController pushViewController:vc animated:YES];

限制了页面跳转的一定是以 push 的形式进行,navigationController 的引用和上一条违反的规则类似,必须保证当前控制器知道当前的上下文关系才能保证逻辑是正确的。

2.2 臃肿的控制器

一个控制器有上千行代码实在是一件非常常见的事情,ViewController 网络请求回调,KVO,delegate 协议的实现,datasouce 协议的实现,按钮点击事件响应,业务逻辑……如果当前业务还需要有加载的 UI,那还需要写一大坨替换 UI 的状态维护的代码(并且加上一堆日志保佑出了问题能查出原因)

事实证明最终所有人都会不约而同选择把上述的代码写在 ViewController 里面——因为太方便了。随着业务迭代很快就会发现这里变得难维护脆弱难以测试。迭代的同学一看到这个控制器就头疼,但是开着飞机换引擎太危险每天都想着如何重构。

但是这能怪我们么?师承苹果——苹果自己的 demo 里面就是这么写代码的。

2.3 解决 MVC 问题的行业方案

为了解决上面这两个的问题,八仙过海各显神通——MVVM, or React, or MVP, or FRP, or VIPER, or go to iOS architecture generator create a new one

澄清一下这里不是在批判上述的设计模式,所有设计模式都有代表了解决问题的思路,都有他们独到的做法值得我们去学习和总结。

为了让逻辑清晰,框架不得不增加限制,导致代码长得和 UIKit 的代码非常不像。这无疑导致了学习成本的大大增加,如果团队人员进出频繁,学习成本势必会大大增加(不光要了解业务逻辑,还要通过框架理解业务)。特别是原始作者走了以后,很多之前设计的初衷甚至都会被打破,渐渐偏离轨道——有可能是框架不再满足现有业务场景了,也可能只是坏代码增加了。

离操作系统越远,对于新特性支持的能力就越弱。每年苹果都会增加不同的 API 以适应更加丰富和强大的功能,比方说 iOS7 preferredStatusBarStyle, iOS8 Size Classes, layoutMargins, iOS 11 Dynamic type, safeAreaLayoutGuide, iOS14 PHPicker。当操作系统行为变了以后,很多框架也需要进化,得把新的特性塞到框架的某一层抽象中。

适配到后来,可能甚至又不得不增加一个单独的中间层去解决。

三、页面跳转:Context, not control

3.1 流程定制化破坏了封装

媒体资源生产流程根据上下文环境不同可能会产生不同的链路,下面两张图代表了生产流程的两种情况。其中每个圆角矩形代表了一个 ViewController 。流程 A 中 C 有两种跳转的可能:DE,流程 B 中 C 固定跳转到 G

遇到的第一个问题:页面跳转在历史迭代过程是靠传参来实现的

1
2
3
4
5
6
7
8
9
10
11
@implementation CViewController
- (void)didClickNext {
if (self.fromFeatureA) {
[self prsentViewController:DViewController];
} else if (self.fromFeatureB) {
[self prsentViewController:EViewController];
} else {
[self prsentViewController:GViewController];
}
}
@end

这里就落入了 MVC 的陷阱:知道了自己不应该知道的细节。编辑页既提供了操作媒体资源的UI又管理了页面流程的跳转,当外面传入业务参数后决定跳转到后续的页面。

In general, a view controller should manage either sequence or UI, but not both.

3.2 Session 充当协调者

解决这类问题就需要建立一个协调者Coordinator,必须把页面跳转逻辑从原始 ViewController 中剥离出来,

Session 在这里给出的解决方案和张一鸣的一句话不谋而合:“Context, not control”(笑)。Session 就是我们需要的协调者,知晓上下文信息信息进行页面跳转。

针对页面跳转目的不同区分成两种 :Process ModuleHandle Scheme,分别代表了媒体数据加工 Pipeline、显式的页面跳转。

为了方便理解,以拍摄、编辑、发布为例,时序图如下:

Process Module

媒体文件生成的流程应该是类似于 pipeline 一样,把一个页面输出加工成另外一个页面需要的输入。

  1. 拍摄页接收相机和麦克风采集到的数据合成视频作为输出
  2. 编辑页拿到拍摄页的输出作为自己的输入后对视频进行再加工(滤镜/剪辑等),将产出的视频作为输出提供给发布页
  3. 发布页拿到编辑页的输出后填充用户描述将视频上传到服务端

这样我们就能写出如下代码,在 session 中添加一个实例将 A 的输出转化为 B 的输入,并且完成页面跳转的逻辑。某个具体的Session 这个类代表了一系列的流程,可以理解为一个生命周期就是一次生产流程的单例,含有的完整的上下文信息,知道当前处理哪一阶段。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
- (NSArray<Class> *)modules {
return @[KSPMPostModule.class,
KSPhotoTakeModule.class,
KSVideoRecordModule.class,
KSPMEditorModule.class,
KSPMPublishModule.class];
}

- (void)processModule:(id<KSProductionModule>)module withSession:(id<KSProductionSession>)session {
if ([module isKindOfClass:KSPhotoTakeModule.class]) {
[self processPhotoTakeModule:(KSPhotoTakeModule *)module withSession:session];
} else if ([module isKindOfClass:KSVideoRecordModule.class]) {
[self processVideoRecordModule:(KSVideoRecordModule *)module withSession:session];
} else if ([module isKindOfClass:KSPMEditorModule.class]) {
[self processEditorModule:(KSPMEditorModule *)module withSession:session];
} else if ([module isKindOfClass:KSPMPublishModule.class]) {
[self processPublishModule:(KSPMPublishModule *)module withSession:session];
}
}

- (void)processPhotoTakeModule:(KSPhotoTakeModule *)module withSession:(id<KSProductionSession>)session {
KSPMEditorScheme *scheme = [KSPMEditorScheme new];
scheme.medias = @[({
KSProductionMedia *media = [KSProductionMedia new];
media.path = [[NSBundle mainBundle] pathForResource:@"photo" ofType:@"jpg"];
media;
})];
__auto_type vc = [session handleScheme:scheme];
[module.viewController.navigationController pushViewController:vc animated:YES];
}

遇到的问题:Session 一期&二期 重构的时候按照这一个逻辑展开了重构,但是执行下来发现生产的流程并不是单一的数据流。举个例子,拍摄页中有3个按钮:音乐、相册、活动挂件,点击三个按钮分别需要跳转到三个不同的页面。跳转到相册页以后用户可以选择系统相册中的资源进行导入、编辑、上传

对于导入流程而言拍摄页的输出(相机和麦克风采集的画面和音频)是不没有用处的。

这证明了路中的跳转行为并不能够用 pipeline 完成完整的抽象,不是每次跳转都有输出的,需要额外增加一种有目的倾向的跳转逻辑——Handle Scheme

Handle Scheme

这里参考了在其他地方被广泛应用的页面跳转路由的思想,将页面跳转逻辑实现在路由中。

Session 负责接收 scheme 消息,类似于打开网页的行为,可以在 URL 中填充参数。Sessionscheme派发给能够处理 schemeModulemodule接收到对应输入以后创建页面。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
@implementation KSPostSession
- (UIViewController *)handleScheme:(id<KSPMScheme>)scheme {
NSArray<Class> *modules = [self modules];
__block UIViewController *schemeViewController = nil;
[modules enumerateObjectsUsingBlock:^(Class _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
Class<KSProductionModule> cls = obj;
if ([cls canHandleScheme:scheme]) {
UIViewController *vc = [cls handleScheme:scheme session:self];
if (!self.rootViewController) {
self.rootViewController = vc;
[[KSProductionSessionManager shareManager] retainSession:self];
[self releaseSessionAfterRootVCDealloc:vc];
}
schemeViewController = vc;
*stop = YES;
}
}];
return schemeViewController;
}
@end

@implementation KSPMEditorModule
+ (UIViewController *)handleScheme:(KSPMEditorScheme *)scheme
session:(id<KSProductionSession>)session {
KSPMEditorModule *module = [[KSPMEditorModule alloc] initWithSession:session];
module.input = scheme.input;
__weak typeof(module) weakModule = module;
module.completionBlock = ^(KSPMEditorModule * _Nonnull module) {
[weakModule.session processModule:weakModule];
};

KSPMEditorViewController *vc = [[KSPMEditorViewController alloc] initWithModule:module];
return vc;
}
@end

总结一下

重构之前

1
2
3
4
5
6
@implementation RecordVC
- (void)click {
AViewController *vc = [[AViewController alloc] init];
[self.navigationController pushViewController:vc animated:YES];
}
@end

重构以后

1
2
3
4
5
6
7
8
@implementation RecordVC
- (void)click {
AScheme *scheme = [AScheme new];
scheme.input = xxx;
__auto_type vc = [session handleScheme:scheme];
[self.navigationController pushViewController:vc animated:YES];
}
@end

我们将具体的 AViewController 类创建的逻辑给隐藏起来了,Module 负责创建 AViewController

Session 通过注入不同的 Module 实现同一个 Scheme 解析出不同 ViewController的能力。

举个例子:生产作为大组件提供给不同 App 使用,拍摄页用的是同一个,但是点击拍摄页的按钮以后期待跳转到不同的相册页(每个 App 都有自己自定义的相册页)。这就可以通过实现两个 Session 来做

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@interface SessionForAppA : KSProductionSession
@end
@implementation SessionForAppA
- (NSArray<Class> *)modules {
return @[ModuleForAppA];
}
@end

@interface SessionForAppB : KSProductionSession
@end
@implementation SessionForAppB
- (NSArray<Class> *)modules {
return @[ModuleForAppB];
}
@end

目前Session 并没有和其他页面跳转路由方案一样将跳转逻辑写在路由中, handleScheme:方法只是创建了一个控制器,这是因为:在没有重构完成以前,Session 虽然有获取足够上下文的能力,但是业务层目前所有的页面跳转都是通过直接引用 self.navigationController 的形式获取导航栈来实现页面的跳转,并没有告知 Session

四、Module & 数据标准化

设计 Module 是为了使流程可定制化,达到约束输入输出的目的

我们的理想是让每个 Module 都能有自己单独的输入和输出流,它的接口定义是这样的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/// 提供 ViewController 需要的数据,约束输入输出
@protocol KSProductionModule <NSObject>

- (instancetype)initWithSession:(nullable id<KSProductionSession>)session;

@property (nonatomic, weak) id<KSProductionSession> session;
@property (nonatomic, weak) UIViewController *viewController;

#pragma mark - input & output
@property (nonatomic, strong) id input;
@property (nonatomic, strong, nullable) id output;
@property (nonatomic, copy) void(^completionBlock)(id<KSProductionModule> module);

+ (UIViewController *)handleScheme:(id<KSPMScheme>)scheme
session:(id<KSProductionSession>)session;

+ (BOOL)canHandleScheme:(id<KSPMScheme>)scheme;

@end

每个模块有自己独立输入和输出,input 应该是强数据结构,显式表明需要什么类型的数据。

1
2
3
4
5
6
7
8
9
10
11
@interface EditorModule : KSProductionModuleBase

@property (nonatomic, strong) EditorModuleInput *input;
@property (nonatomic, strong, nullable) EditorModuleOutput *output;

@property (nonatomic, copy) void(^completionBlock)(EditorModule *module);
@end

@interface EditorModuleInput : NSObject
@property (nonatomic) NSArray<Media *> *medias;
@end

4.1 Module

还是以这图为例,在上述流程中每个圆角矩形都代表了一个全屏的页面,每个全屏页面一般等价于一个 ModuleSession 管理 Module 的创建

为了方便理解 Module 的逻辑,这里线上有的 MVVM 的页面如何改造呢为例阐述重构思路。

参考在 MVVM 中介绍的 ,表示逻辑从 Controller 移出放到一个新的对象里,即 ViewModel

ViewController 初始化的时候传入 ViewModel

1
2
3
4
5
6
@interface HomeViewModel : NSObject
@end

@interface HomeViewController : UIViewController
- (instancetype)initWithViewModel:(HomeViewModel *)vm;
@end

重构以后 Module 负责创建 ViewModelVC 通过 module.input.viewModel 访问到原来的 ViewModel。(以及session)。

1
2
3
4
5
6
7
8
9
@protocol HomeViewModule <NSObject>
@property (nonatomic, weak) id<KSProductionSession> session
@property (nonatomic) HomeViewModel *vm;
@property (nonatomic) HomeViewModuleInput input;
@end

@interface HomeViewController : UIViewController
- (instancetype)initWithModule:(id<HomeViewModule>)module;
@end

这样就能在够不破坏原有架构的前提下完成 session 的改造步骤。

4.2 数据标准化

需要注意的是,Module 负责创建控制器,所以ViewModel不能直接继承 Module。图中ViewController 虽然持有的是一个实现了某抽象协议的对象,而不应该是某个类,这个协议对象显式写明了要求的输入 input

1
2
3
4
@protocol HomeViewModule <NSObject>
@property (nonatomic) HomeViewModuleInput input;
@property (nonatomic) HomeViewModuleOutput output;
@end

保证输入输出一致的前提下,替换 Module 的实现就能达到定制化链路流程。组件化不再是大礼包,而是支持单模块替换的。


未完待续:写到这里写不动了,暂时先拆个上篇吧

参考文档

A Better MVC, Part 1: The Problems