iOS 基于MVVM + RAC + ViewModel-Bas
前言
- 由于最近两个多月,笔者正和小伙伴们忙于对公司新项目的开发,笔者主要负责项目整体架构的搭建以及功能模块的分工。首先,该项目采用
MVVM + RAC + ViewModel-Based Navigation
的设计模式,其次,尝试利用ViewModel-Based
来实现导航(push/pop
和present/dismiss
)操作。最后,该项目在经过两个月的埋头苦干,也于近期成功上架AppStore【轻空-母婴二手用品寄售平台】。考虑到公司项目文件的保密性,这里笔者绝不会共享源码,而是采用笔者公司项目的同一套架构,来一步一步实现微信整体架构功能的开发。其目的就是让大家更加深沉次的领会MVVM
设计模式,以及利用ViewModel-Based
来实现导航(push/pop
和present/dismiss
)操作的优越性。 -
MVVM With ReactiveCocoa
的架构设计以及ViewModel-Based Navigation
导航方式,主要参照的是雷纯锋大神开源的MVVMReactiveCocoa的框架,在其架构的基础上进行一系列改进和一些新特性的增加,不断丰富该架构以此来满足不同的开发场景,从而一步一步实现微信的基本架构,同时也侧面验证了雷纯锋大神的MVVM + RAC + ViewModel-Based Navigation
的理论正确性和有效性,同时也希望能够打消你对MVVM + RAC + ViewModel-Based Navigation
模式的顾虑。 - 本文将着重分析利用
MVVM + RAC + ViewModel-Based Navigation
的方式来设计和实践微信(WeChat)
大体功能的开发,希望大家能有所收获,并将其运用到自己的实际项目中去,这才是此文的最大意义。笔者也将知无不言言无不尽的将其里面的核心分享给大家,同时在运用到实际开发中遇到问题以及解决办法贡献出来,希望大家在使用这套模式来开发的时候知其然知其所以然
,为大家提供一点思路,少走一些弯路,填补一些细坑。文章仅供大家参考,若有不妥之处,还望不吝赐教,欢迎批评指正。 -
MVVM + ReactiveCocoa
的使用不了解的,请猛戳我iOS 关于MVVM With ReactiveCocoa设计模式的那些事 -
ViewModel-Based Navigation
的使用不了解的,请猛戳我 MVVM With ReactiveCocoa - 文章略长,先马后看。
代码结构
-
结构
CodeStructure.png
-
说明
-
Model :存放数据-模型(
data-model
),例如:MHUser
. -
View:存放
功能模块
自定义的View。例如:MHMainFrameTableViewCell
. -
ViewController:存放
功能模块
的是视图控制器。例如:MHMainFrameViewController
. -
ViewModel:存放
功能模块
的是视图对应的视图模型。例如:MHMainFrameViewModel
. -
Utils:存放工具类和管理类。例如:分类
Category
,网络服务层MHHTTPService
,管理类MHFileManager
... -
Vendor:存放第三方框架。例如:
MJRefresh
... -
Macros:存放常量。例如:宏(
#define
)定义常量,const
常量,枚举(NS_ENUM
)常量,inline
函数,URL
路径常量。 -
Resource:存放资源文件,例如:图片,
Data
,SQL
文件。
-
Model :存放数据-模型(
-
细节
- 代码结构完全按照
MVVM
来设计命名,实际上MVVM
的V
应该包括视图控制器(ViewController)
和视图(View)
,这里只是将其单独分开,以便于更好的阅读和开发。 - 必须强调
文件夹
的命名,这里笔者是按照主功能模块
来命名,相信大家可以很清楚的看到View
、ViewController
、ViewModel
三个文件夹里面的子模块文件夹都是一样的。而后期若在设计子文件夹的时候,参照这种方式来创建文件夹,那么大家会发现,你的代码目录会非常非常的整齐漂亮,同时方便后期维护和其他开发人员阅读代码,何乐而不为呢。 - 同时强调一下自定义的视图控制器和视图模型的命名,理论上,一个视图控制器配备一个视图模型,所以笔者这里只是将视图控制的名字的
ViewController
替换成ViewModel
即为配备的视图模型的名字:例如:视图控制器的名字为MHMainFrameViewController
,则视图模型的名字为MHMainFrameViewModel
。这样整个项目开发下来,你会发现ViewController
和ViewModel
文件下的文件都是对称的。 - 目录层级不能超过
三层
。因为层级越深,越不易查找,且不易阅读。这里就以我的(Profile)
为例,我的(Profile)
界面有一个用户信息(UserInfo)
子模块,用户信息(UserInfo)
里面有一个更多(MoreInfo)
子模块,更多(MoreInfo)
模块当然也有子模块等等。如果这样划分,必然会导致目录结构很深,所以为了避免其发生,就尽量限制在三层即可,正所谓事不过三嘛,所谓三层目录可想而知,就是ViewController - Profile - UserInfo
这三层便是,那么我们就可将更多(MoreInfo)
模块与用户信息(UserInfo)
并列即可,当然你也可以将更多(MoreInfo)
模块的写在用户信息(UserInfo)
里面,但是只创建文件,而不创建文件夹。只要保证不超过三层目录即可。即如下图所示:
- 代码结构完全按照
第三方框架
第三方框架想必对与小伙伴在熟悉不过了,其作用简而言之就是:辅助。让我们更专注于产品的业务逻辑开发,而不是某个功能点开发。这里简单介绍一下此次搭建微信(WeChat)
基本架构中主要用到的第三方框架。目的希望能够让大家学习更多更好用的轮子
,以及结合自身项目的实际情况集成进去,减少不必要的开发。更多详见Demo的Podfile
文件。
- AFNetworking :用于网络数据请求。
- SDWebImage:图片异步加载和缓存。
-
ReactiveCocoa:函数响应式编程工具,主要用于
MVVM
设计模式的数据绑定。本项目使用的是pod 'ReactiveCocoa' ,'2.5'
的版本。 - Masonry:是一个轻量级的布局框架,拥有自己的描述语法,采用更优雅的链式语法封装自动布局,简洁明了并具有高可读性。
- IQKeyboardManager:键盘管理工具,优雅的解决弹起键盘遮盖输入框的问题。
-
YYKit:一套比较齐全的iOS开发组件。以下是项目中常用到的几个组件。
-
YYCategories:为
Foundation and UIKit
提供许多有用的分类。 - YYText:强大的iOS富文本组件。
- YYModel:高性能的字典转模型的框架。
- YYImage:功能强大的图像框架。
- YYWebImage:异步图片加载框架。[注:本项目主要使用:YYWebImage来加载图片,而SDWebImage主要兼容其他第三方框架]
-
YYCache:高性能 iOS 缓存框架,提供
内存缓存
和磁盘缓存
。
-
YYCategories:为
- UITableView+FDTemplateLayoutCell:自动计算cell高度并缓存cell高度。
- FDFullscreenPopGesture:全屏左滑pop手势。
- FMDB:SQLite数据库。
-
MJExtension:字典转模型框架。[注:该项目使用
YYModel
来做字典转模型,而MJExtension
作为辅助.]。 - MJRefresh:下拉刷新和上拉加载控件。
- pop:动画引擎,用于动画过渡。若不会使用,请参照popping。
- DZNEmptyDataSet:UITableView/UICollectionView数据内容为空时展示的空白页。
- MBProgressHUD:加载loading以及显示提示蒙版的HUD。
-
JPFPSStatus:通过
FPS(Frames Per Second)
每秒传输帧数的高低来检查列表滚动的流畅度。
BaseClass
本项目中采用的是继承
的方式来设计的,所以BaseClass
的存在在所难免,但是它在项目中的作用是举足轻重的,简直神一样的存在。笔者这里主要详述Model
、ViewController
、ViewModel
中的BaseClass
,而View
中的BaseClass
无非是实际项目中开发者自定义的功能View
,方便后期要使用只需继承该功能View
就可以了,减少了开发中的冗余代码。比如:笔者项目中的MHButton
是继承于UIButton
,而其作用只是去掉了按钮的高亮状态- (void)setHighlighted:(BOOL)highlighted {}
,以及MHImageView
是继承于UIImageView
,而其作用只是增加了允许用户的交互self.userInteractionEnabled = YES;
。这里主要解析的各个是BaseClass
的头文件的属性和方法,以及各自的使用场景和注意点。基类主要文件如下:
MHObject:所有数据模型的基类。
MHViewModel/MHViewController:所有自定义视图控制器的基类,以及配备的视图模型。
MHTableViewModel/MHTableViewController:所有需要显示UITableView的自定义视图控制器的基类,以及配备的视图模型。
MHWebViewModel/MHWebViewController:所有需要显示WKWebView的自定义视图控制器的基类,以及配备的视图模型。
MHTabBarViewModel/MHTabBarController:需要展示UITabBarController的自定义视图控制器,以及配备的视图模型。
-
Model -- BaseClass
MHObject
是整个项目的数据-模型(Data-Model)
的基类,即:JSON
转成的模型的基类。MHObject
遵守YYModel协议,MHObject.h
文件的API
也参照NSObject+YYModel.h
的API
的实现,内部封装了YYModel对应的字典转模型的主要方法。所以使用前提你得会使用YYModel,这里笔者仅说明MHObjec.h
的属性和方法,具体的实现请移步笔者提供的Demo来阅读和理解。MHObject.h
内容如下: -
ViewModel -- BaseClass
MHViewModel是整个项目所有自定义的视图模型的基类,主要提供数据给MHViewController
,主要职责就是从model
层获取view
所需的数据,并且将这些数据转换成view
能够展示的形式。当然这里笔者为其配备了许多常用的属性:是否允许左滑pop到上一层的interactivePopDisabled
、是否需要隐藏导航栏的prefersNavigationBarHidden
、是否需要隐藏导航栏底部细线的prefersNavigationBarBottomLineHidden
、是否启用IQKeyboardManager来管理键盘的弹起和关闭的keyboardEnable
等...大家可以根据项目中的实际情况来配置各个属性的值,当然你也可以为其配备更多更好用的功能,以次来快速实现产品需求和避免冗余代码的产生。MHViewModel
的其他属性或方法这里就不一一叙述了,大家可以根据笔者的属性注释设置其值,运行起来看看具体的效果即可。MHViewModel.h
的内容如下:/// MVVM View /// The base map of 'params' /// The `params` parameter in `-initWithParams:` method. /// Key-Values's key /// 传递唯一ID的key:例如:商品id 用户id... FOUNDATION_EXTERN NSString *const MHViewModelIDKey; /// 传递导航栏title的key:例如 导航栏的title... FOUNDATION_EXTERN NSString *const MHViewModelTitleKey; /// 传递数据模型的key:例如 商品模型的传递 用户模型的传递... FOUNDATION_EXTERN NSString *const MHViewModelUtilKey; /// 传递webView Request的key:例如 webView request... FOUNDATION_EXTERN NSString *const MHViewModelRequestKey; @protocol MHViewModelServices; @interface MHViewModel : NSObject /// Initialization method. This is the preferred way to create a new view model. /// services - The service bus of the `Model` layer. /// params - The parameters to be passed to view model. /// /// Returns a new view model. - (instancetype)initWithServices:(id<MHViewModelServices>)services params:(NSDictionary *)params; /// The `services` parameter in `-initWithServices:params:` method. @property (nonatomic, readonly, strong) id<MHViewModelServices> services; /// The `params` parameter in `-initWithParams:` method. /// The `params` Key's `kBaseViewModelParamsKey` @property (nonatomic, readonly, copy) NSDictionary *params; /// navItem.title @property (nonatomic, readwrite, copy) NSString *title; /// 返回按钮的title,default is nil 。 /// 如果设置了该值,那么当Push到一个新的控制器,则导航栏左侧返回按钮的title为backTitle @property (nonatomic, readwrite, copy) NSString *backTitle; /// The callback block. 当Push/Present时,通过block反向传值 @property (nonatomic, readwrite, copy) VoidBlock_id callback; /// A RACSubject object, which representing all errors occurred in view model. @property (nonatomic, readonly, strong) RACSubject *errors; /** should fetch local data when viewModel init . default is YES */ @property (nonatomic, readwrite, assign) BOOL shouldFetchLocalDataOnViewModelInitialize; /** should request data when viewController videwDidLoad . default is YES*/ /** 是否需要在控制器viewDidLoad */ @property (nonatomic, readwrite, assign) BOOL shouldRequestRemoteDataOnViewDidLoad; /// will disappear signal @property (nonatomic, strong, readonly) RACSubject *willDisappearSignal; /// FDFullscreenPopGesture /// Whether the interactive pop gesture is disabled when contained in a navigation /// stack. (是否取消掉左滑pop到上一层的功能(栈底控制器无效),默认为NO,不取消) @property (nonatomic, readwrite, assign) BOOL interactivePopDisabled; /// Indicate this view controller prefers its navigation bar hidden or not, /// checked when view controller based navigation bar's appearance is enabled. /// Default to NO, bars are more likely to show. /// 是否隐藏该控制器的导航栏 默认是不隐藏 (NO) @property (nonatomic, readwrite, assign) BOOL prefersNavigationBarHidden; /// 是否隐藏该控制器的导航栏底部的分割线 默认不隐藏 (NO) @property (nonatomic, readwrite, assign) BOOL prefersNavigationBarBottomLineHidden; /// IQKeyboardManager /// 是否让IQKeyboardManager的管理键盘的事件 默认是YES(键盘管理) @property (nonatomic, readwrite, assign) BOOL keyboardEnable; /// 是否键盘弹起的时候,点击其他局域键盘弹起 默认是 YES @property (nonatomic, readwrite, assign) BOOL shouldResignOnTouchOutside; /// An additional method, in which you can initialize data, RACCommand etc. /// /// This method will be execute after the execution of `-initWithParams:` method. But /// the premise is that you need to inherit `BaseViewModel`. - (void)initialize; @end
MHWebViewModel主要是为要加载网页
(WKWebView)
的视图MHWebViewController
提供数据的数据模型基类,继承于MHViewModel
。其头文件暴露的属性也比较简单,都是平常开发中会遇到的,只要大家稍加利用,就能完成一些常用的功能。MHWebViewModel.h
内容如下:@interface MHWebViewModel : MHViewModel /// web url quest @property (nonatomic, readwrite, copy) NSURLRequest *request; /// 下拉刷新 defalut is NO @property (nonatomic, readwrite, assign) BOOL shouldPullDownToRefresh; /// 是否取消导航栏的title等于webView的title。默认是不取消,default is NO @property (nonatomic, readwrite, assign) BOOL shouldDisableWebViewTitle; /// 是否取消关闭按钮。默认是不取消,default is NO @property (nonatomic, readwrite, assign) BOOL shouldDisableWebViewClose; @end
这里笔者讲讲
shouldDisableWebViewTitle
和shouldDisableWebViewClose
这两个属性的作用以及使用场景。
shouldDisableWebViewTitle
: 是否取消导航栏的title
等于webView
的title
。默认做法是MHWebViewController
及其子类的导航栏title
为WebView
的title
,而不是MHViewModel
的title
属性。即控制器通过KVO
的形式监听WKWebView
的title
属性,从而设置导航栏的title
,self.navigationItem.title = self.webView.title
。但是可能有几个H5
界面想要设置导航栏的title
为MHViewModel
的title
属性,正所谓需求拉动生成,所以就产生了该属性。
shouldDisableWebViewClose
:是否导航栏左侧取消关闭按钮,默认是不取消。这主要是为了解决点击网页里面的链接继续加载另一个网页,如果重复前面的步骤几次,则网页层次就会非常的深(A - B - C - D - E ...)。如果我们点击MHWebViewController
导航栏的左侧的返回按钮,其默认做法是返回到上一个网页([self.webView goBack]
),这样由于前面的步骤,导致网页层次过深,我们需要点击多次返回按钮,才能返回到最初的网页,继而才能返回上一个界面,这样用户操作过多,用户体验下降(PS:干着程序猿的活,抄着产品经理的心)。MHWebViewController
的导航栏返回按钮的事件处理代码如下:- (void)_backItemDidClicked{ /// 返回按钮事件处理 /// 可以返回到上一个网页,就返回到上一个网页 if (self.webView.canGoBack) { [self.webView goBack]; }else{/// 不能返回上一个网页,就返回到上一个界面 /// 判断 是Push还是Present进来的, if (self.presentingViewController) { [self.viewModel.services dismissViewModelAnimated:YES completion:NULL]; } else { [self.viewModel.services popViewModelAnimated:YES]; } } }
所以,这时候为了解决此类问题,于是就出现了,当发现
WKWebView
能返回到上一个网页(self.webView.canGoBack
),那么就会让导航栏左侧(leftBarButtonItems
)同时显示返回和关闭按钮,当我们点击关闭按钮,就直接返回到上一层页面而不是返回上一个网页。当然有些页面是不要显示关闭按钮的,比如一些网页点击跳转顶多两三层。所以该属性就是为了显示和隐藏关闭按钮而产生的。下面就是MHWebViewController
中显示关闭按钮以及关闭按钮的事件处理的代码:/// 内容开始返回时调用 - (void)webView:(WKWebView *)webView didCommitNavigation:(null_unspecified WKNavigation *)navigation { /// 不显示关闭按钮 if(self.viewModel.shouldDisableWebViewClose) return; UIBarButtonItem *backItem = self.navigationItem.leftBarButtonItems.firstObject; if (backItem) { if ([self.webView canGoBack]) { [self.navigationItem setLeftBarButtonItems:@[backItem, self.closeItem]]; } else { [self.navigationItem setLeftBarButtonItems:@[backItem]]; } } } - (void)_closeItemDidClicked{ /// 判断 是Push还是Present进来的 if (self.presentingViewController) { [self.viewModel.services dismissViewModelAnimated:YES completion:NULL]; } else { [self.viewModel.services popViewModelAnimated:YES]; } }
MHTableViewModel主要是提供数据给
MHTableViewController
的视图模型的基类,继承于MHViewModel
,且MHTableViewModel
在本项目中使用最为广泛。当然笔者也为其增添许多功能属性,以此来加快了开发的便捷度以及减少了子类代码的冗余度。具体的的使用请根据笔者提供的属性注释,根据自身项目来配置其属性的值。MHTableViewModel.h
具体内容如下:@interface MHTableViewModel : MHViewModel /// The data source of table view. 这里不能用NSMutableArray,因为NSMutableArray不支持KVO,不能被RACObserve @property (nonatomic, readwrite, copy) NSArray *dataSource; /// tableView‘s style defalut is UITableViewStylePlain , 只适合 UITableView 有效 @property (nonatomic, readwrite, assign) UITableViewStyle style; /// 需要支持下来刷新 defalut is NO @property (nonatomic, readwrite, assign) BOOL shouldPullDownToRefresh; /// 需要支持上拉加载 defalut is NO @property (nonatomic, readwrite, assign) BOOL shouldPullUpToLoadMore; /// 是否数据是多段 (It's effect tableView's dataSource 'numberOfSectionsInTableView:') defalut is NO @property (nonatomic, readwrite, assign) BOOL shouldMultiSections; /// 是否在上拉加载后的数据,dataSource.count < pageSize 提示没有更多的数据.default is NO 默认做法是数据不够时,隐藏mj_footer @property (nonatomic, readwrite, assign) BOOL shouldEndRefreshingWithNoMoreData; /// 当前页 defalut is 1 @property (nonatomic, readwrite, assign) NSUInteger page; /// 每一页的数据 defalut is 20 @property (nonatomic, readwrite, assign) NSUInteger perPage; /// 选中命令 eg: didSelectRowAtIndexPath: @property (nonatomic, readwrite, strong) RACCommand *didSelectCommand; /// 请求服务器数据的命令 @property (nonatomic, readonly, strong) RACCommand *requestRemoteDataCommand; /// 占位empty类型 //@property (nonatomic, readwrite, assign) SBDefaultEmptyBackgroundType emptyType; /// 网络不可用 default is NO @property (nonatomic, readwrite, assign) BOOL disableNetwork; /** fetch the local data */ - (id)fetchLocalData; /// 请求错误信息过滤 - (BOOL (^)(NSError *error))requestRemoteDataErrorsFilter; /// 当前页之前的所有数据 - (NSUInteger)offsetForPage:(NSUInteger)page; /** request remote data or local data, sub class can override it * page - 请求第几页的数据 */ - (RACSignal *)requestRemoteDataSignalWithPage:(NSUInteger)page; @end
-
ViewController -- BaseClass
MHNavigationController :是整个项目所使用的导航栏控制器,用于替代系统的导航栏控制器(UINavigationController)
,当开发需要Push/Present
一个导航栏控制器,我们应该Push/Present
的是MHNavigationController
,而不是UINavigationController
。当然MHNavigationController
不是单纯只是简单的继承UINavigationController
就完事了,笔者也是赋予了MHNavigationController
一些使命的。MHNavigationController.h
内容如下:@interface MHNavigationController : UINavigationController /// 显示导航栏的细线 - (void)showNavigationBottomLine; /// 隐藏导航栏的细线 - (void)hideNavigationBottomLine; @end
默认情况下,系统导航栏控制器的
navigationBar
底部有一根深灰色的细线(UIImageView
),现实开发中,大家肯定遭遇到产品经理这样的Diss:" 该界面能否隐藏导航栏底部这根细线?" " 该界面为何要隐藏导航栏底部这根细线?" " 有没有觉得导航栏底部这根细线颜色太深?" " 有没有觉得导航栏底部这根细线过高?" ...
理想很丰满,现实很骨感
,哎,说多了都是泪。于是乎,为了满足产品的需求,便诞生了MHNavigationController.h
中显示和隐藏导航栏底部细线的方法,一般这两个方法都是成对出现的,在ViewController
的viewWillAppear:
和viewWillDisappear:
来控制导航栏底部细线的显示和隐藏。
其实网络上有很多隐藏导航栏底部细线的方法,这里讲讲笔者的做法,其实很简单,就是:找到它,隐藏它,自定义细线。代码如下:// 查询最后一条数据 - (UIImageView *)_findHairlineImageViewUnder:(UIView *)view{ if ([view isKindOfClass:UIImageView.class] && view.bounds.size.height <= 1.0) { return (UIImageView *)view; } for (UIView *subview in view.subviews){ UIImageView *imageView = [self _findHairlineImageViewUnder:subview]; if (imageView){ return imageView; } } return nil; } #pragma mark - 设置导航栏的分割线 - (void)_setupNavigationBarBottomLine{ //!!!:这里之前设置系统的 navigationBarBottomLine.image = xxx;无效 Why? 隐藏了系统的 自己添加了一个分割线 // 隐藏系统的导航栏分割线 UIImageView *navigationBarBottomLine = [self _findHairlineImageViewUnder:self.navigationBar]; navigationBarBottomLine.hidden = YES; // 添加自己的分割线 CGFloat navSystemLineH = .5f; UIImageView *navSystemLine = [[UIImageView alloc] initWithFrame:CGRectMake(0, self.navigationBar.mh_height - navSystemLineH, MH_SCREEN_WIDTH, navSystemLineH)]; navSystemLine.backgroundColor = MHColor(223.0f, 223.0f, 221.0f); [self.navigationBar addSubview:navSystemLine]; self.navigationBottomLine = navSystemLine; }
其实,
MHNavigationController
最大的使命是:拦截系统的Push
进来的所有子控制器,以便于统一处理:隐藏和显示系统底部的UITabBar
,统一处理Push过来的子控制器的导航栏的左侧按钮(navigationItem.leftBarButtonItem)的返回样式以及事件处理
。当然返回按钮(leftBarButtonItem)
的样式虽是多种多样的,比如:直接显示返回
二字的 ,也有显示一张<
图片的,也有显示< xxx
的。但事件是统一的,都是调用popViewControllerAnimated:
来返回上一个界面。当然,你也可以在指定的ViewController
里面,自定义设置导航栏左侧的navigationItem.leftBarButtonItem
的样式,以及实现该leftBarButtonItem
的事件即可。这里笔者以统一处理微信(WeChat)
的返回按钮样式为例。说说笔者的思路,首先讲讲微信(WeChat)
返回按钮的样式的需求伪代码:假设有两个控制器(A/B),且A.title = @"KKK"
、B.title = @"ZZZ"
,假设[A Push B]
,那么微信的默认做法,则B
的导航栏返回按钮是< KKK
,也就是B
的导航栏返回按钮的title
是A.title
。当然如果考虑到A.title
的文字很长,那么需要自定义B
的导航栏返回按钮的title
是< XXX
。(大家没绕晕吧...)。这种自定义的做法需要结合MHViewModel
的backTitle
属性。详见代码如下:/// 能拦截所有push进来的子控制器 - (void)pushViewController:(UIViewController *)viewController animated:(BOOL)animated{ // 如果现在push的不是栈底控制器(最先push进来的那个控制器) if (self.viewControllers.count > 0){ /// 隐藏底部tabbar viewController.hidesBottomBarWhenPushed = YES; NSString *title = @"返回"; /// eg: [A push B] /// 1.取出当前的控制器的title , 也就是取出 A.title title = [[self topViewController] title]?:@"返回"; /// 2.判断要被Push的控制器(B)是否是 MHViewController , if ([viewController isKindOfClass:[MHViewController class]]) { MHViewModel *viewModel = [(MHViewController *)viewController viewModel]; /// 3. 查看backTitle 是否有值 title = viewModel.backTitle?:title; } // 4.这里可以设置导航栏的左右按钮 统一管理方法 viewController.navigationItem.leftBarButtonItem = [UIBarButtonItem mh_backItemWithTitle:title imageName:@"barbuttonicon_back_15x30" target:self action:@selector(_back)]; } // push [super pushViewController:viewController animated:animated]; } /// 事件处理 - (void)_back{ [self popViewControllerAnimated:YES]; }
MHNavigationController
当然还有一些其他使命,比如统一设置UINavigationBar
和UIBarButtonItem
的主题。这里就不一一阐述了,详见Demo里面的MHNavigationController.m
文件。(PS:天青色等烟雨,而我在等你)。MHViewController 是整个项目中所有自定义的视图控制器的基类。其主要使命是绑定
MHViewModel
提供的一系列属性来完成一些初始化工作和基础性的配置。MHViewController.h
内容如下:@interface MHViewController : UIViewController /// The `viewModel` parameter in `-initWithViewModel:` method. @property (nonatomic, readonly, strong) MHViewModel *viewModel; /// 截图(Push/Pop Present/Dismiss 过度过程中的缩略图) @property (nonatomic, readwrite, strong) UIView *snapshot; /** 统一使用该方法初始化,子类中直接声明对于的'readonly' 的 'viewModel'属性, 并在@implementation内部加上关键词 '@dynamic viewModel;' @dynamic A相当于告诉编译器:“参数A的getter和setter方法并不在此处, 而在其他地方实现了或者生成了,当你程序运行的时候你就知道了, 所以别警告我了”这样程序在运行的时候, 对应参数的getter和setter方法就会在其他地方去寻找,比如父类。 */ /// Initialization method. This is the preferred way to create a new view. /// /// viewModel - corresponding view model /// /// Returns a new view. - (instancetype)initWithViewModel:(MHViewModel *)viewModel; /// Binds the corresponding view model to the view.(绑定数据模型) - (void)bindViewModel; @end
通过API可见
MHViewController
的功能其实是比较单一的,只做了绑定视图模型(MHViewModel及其子类
)的一些基础性配置。更多内容详见Demo的MHViewController.m
文件,笔者这里讲讲根据MHViewModel
的title
的属性设置导航栏title
的细节,代码和细节处理如下所述:/// set navgation title // CoderMikeHe Fixed: 这里只是单纯设置导航栏的title。 不然以免self.title同时设置了navigatiItem.title, 同时又设置了tabBarItem.title RAC(self.navigationItem , title) = RACObserve(self, viewModel.title);
MHWebViewController是整个项目中所有需要显示
WebView(WKWebView)
的自定义的视图控制器的基类。其内部添加了一个全屏的WKWebView
作为视图控制器View
的子控件,主要目的是为了加载一些网页链接以及本地H5,开发中只需要直接使用MHWebViewController
即可,很少需要将其子类化。通过绑定MHWebViewModel
的request
属性来加载指定的网页,只要你能熟练使用WkWebView
即可,其他的细节问题比如下拉刷新网页、WKWebView
自适应屏幕、点击网页链接跳转处理,以及多次跳转网页后的导航栏关闭按钮的事件处理等... 请参考MHWebViewController.m
。MHWebViewController.h
的头文件内容如下:@interface MHWebViewController : MHViewController<WKNavigationDelegate,WKUIDelegate,WKScriptMessageHandler> /// webView @property (nonatomic, weak, readonly) WKWebView *webView; /// 内容缩进 (64,0,0,0) @property (nonatomic, readonly, assign) UIEdgeInsets contentInset; @end
MHTabBarController在本项目继承于
MHViewController
,主要作用是将UITabBarController
作为自己的子控制器,并将tabBarController
作为一个只读(readonly)
属性暴露在头文件中,以便子类能够获取并使用,即关键代码如下:self.tabBarController = [[UITabBarController alloc] init]; /// 添加子控制器 [self.view addSubview:self.tabBarController.view]; [self addChildViewController:self.tabBarController]; [self.tabBarController didMoveToParentViewController:self];
大家可能普遍会认为,
MHTabBarController
为何是继承MHViewController
,而不是直接继承UITabBarController
(PS:若为MVC
模式,笔者定会直接继承UITabBarController
),这样岂不更加清晰明了。笔者认为这主要是为了保证整个项目继承的连续性,以便更好的使用到基类的属性和方法,保证代码的规范性。
本项目主模块的视图控制器继承关系为:
MHHomePageViewController → MHTabBarController → MHViewController
,
本项目主模块的视图模型的继承关系为:
MHHomePageViewModel → MHTabBarViewModel → MHViewModel
,
如果直接单纯的继承UITabBarController
,则继承关系为:
MHHomePageViewController → MHTabBarController → UITabBarController
然而,UITabBarController
是继承于UIViewController
的,这样就使得与MHViewController
失去了联系,从而无法使用MHViewController
中的属性和方法。同理,视图模型的继承连续性也可以以此类比。
当然,MHTabBarController
内部还利用了KVC
将其系统的tabBar
替换成MHTabBar
(PS:继承UITabBar
)。代码如下:// kvc替换系统的tabBar MHTabBar *tabbar = [[MHTabBar alloc] init]; //kvc实质是修改了系统的_tabBar [self.tabBarController setValue:tabbar forKeyPath:@"tabBar"];
其目的就是便于更好的定制适合产品需求的
UITabBar
,比如:UITabBar
顶部的细线颜色问题,高度问题 ,中间添加加号按钮等...解决方案类似导航栏的navigationBar
类似,即找到它,隐藏它,自定义细线。更多内容请参见Demo中的MHTabBarController
和MHTabBar
即可。MHTabBarController.h
内容如下@interface MHTabBarController : MHViewController<UITabBarControllerDelegate> /// The `tabBarController` instance @property (nonatomic, readonly, strong) UITabBarController *tabBarController; @end
MHTableViewController是整个项目中所有需要显示
列表(UITableView)
的自定义的视图控制器的基类,也是项目中使用最多的基类。MHTableViewController
内部添加了一个全屏的UITableView
作为其子控件,通过配合绑定MHTableViewModel
的属性来实现tableView的展示样式
,tableView的数据展示
,tableView是否支持上拉加载和下拉刷新以及加载和刷新的逻辑
,tableView无数据或无网络的展示
,tableView选中cell的事件处理
。开发中我们绝大多数都是通过子类化MHTableViewController
,然后重写(Override)
父类提供的方法来配置tableView的contentInsert
,提供tableView展示数据的cell
,绑定cell显示的数据模型
等等。关键是要学会根据项目需求来配置MHTableViewModel
的属性,依次来达到产品的需求。在此可见MVVM
中VM(视图模型)
的重要性。MHTableViewController.h
的内容如下:@interface MHTableViewController : MHViewController<UITableViewDelegate , UITableViewDataSource> /// The table view for tableView controller. /// tableView @property (nonatomic, readonly, weak) UITableView *tableView; /// `tableView` 的内容缩进,default is UIEdgeInsetsMake(64,0,0,0),you can override it @property (nonatomic, readonly, assign) UIEdgeInsets contentInset; /// reload tableView data , sub class can override - (void)reloadData; /// dequeueReusableCell - (UITableViewCell *)tableView:(UITableView *)tableView dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath; /// configure cell data - (void)configureCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath withObject:(id)object; @end
这里笔者讲讲在设计
MHTableViewController
时遇到的坑和填坑的办法,以及部分关键代码的解析,希望可以帮助大家在开发中更好的理解和避免被坑。
内置tableView
的尺寸布局的坑。由于项目中纯代码部分笔者都是利用Masonry
来实现布局的,所以在MHTableViewController
中布局tableView
时,利用Masonry
来布局,关键代码如下:UITableView *tableView = [[UITableView alloc] initWithFrame:CGRectZero style:self.viewModel.style]; [self.view addSubview:tableView]; [tableView mas_makeConstraints:^(MASConstraintMaker *make) { make.edges.mas_equalTo(UIEdgeInsetsZero); }];
其实,正常情况下完全没问题,但是
UITableView崩溃.pngMHTableViewController
子类化后,在子类中设置了tableView
的contentInset
属性,然而tableView
的contentOffset
始终是(0,0)
,非常的神奇,到目前为止笔者也不知其原因(PS:若知道的大神, 请说一声哦),这样就导致了笔者一个需求上的Bug,就是笔者项目中首页是个商品列表,当你向下滑动到一定距离,屏幕右下角处会出现一个能够点击滚动到顶部的按钮,点击向上按钮就可以滚动到顶部即可。实现过程无非就是监听按钮的点击方法,实现[self.tableView setContentOffset:CGPointMake(0, 0) animated:YES];
即可(理论上)。但是如果采用Masonry
布局,就会出现点击向上按钮,你怎么也滚动不到顶部去,感觉tableView
抽风了。当然,大家可以利用笔者提供的MHDevelopExample_Objective_C的MVVM
那块的内容进行复现或调试。
笔者采取的解决办法是:笔者首先觉得可能tableView
还未布局好而导致的,所以在利用Masonry
布局tableView
时,在MHTableViewController
中强制布局了子控件,即调用[self.view layoutIfNeeded];
,结果也很神奇,就可以实现点击向上按钮,能滚动到顶部了。
但是...BUG还是出现了。如果MHTableViewModel
的dataSource
的数据不是通过- (RACSignal *)requestRemoteDataSignalWithPage:(NSUInteger)page
来获取的网络数据,而是在- (void)initialize
中就初始化的死数据,例如发现模块
页面中cell
的数据源。当我们的Cell
是xib
创建,且一般开发中会在MHTableViewController
的子类中的-(void)viewDidLoad
里面注册tableViewCell
。切记:Bug复现条件必须是:TableViewModel
的dataSource
是必须死(本地)数据,而非网络数据,并且是Cell
是用tableView
注册来获取的,缺一不可。这样会导致如下图所示的Bug。
如果开启全局断点,那么会崩溃定位到[self.view layoutIfNeeded]的位置,由于
强制布局(layoutIfNeeded)
视图控制器的子控件,那么会导致tableView
提前刷新(reloadData)
其数据源的方法,而此时TableViewModel
的dataSource
的数据又是本地数据,一开始是会有值,从而会调用tableView
的数据源方法:- (UITableViewCell *)tableView:(UITableView *)tableView cellForRowAtIndexPath:(NSIndexPath *)indexPath
,而一般初始化cell
的工作都是交个子类来重写MHTableViewController
的- (UITableViewCell *)tableView:(UITableView *)tableView dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath
的方法。所以当我们在子类的-(void)viewDidLoad
中注册TableViewCell
,这样就会因为代码调用顺序的原因,使得子类通过在重写- (UITableViewCell *)tableView:(UITableView *)tableView dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath
来返回一个cell
,然而return [tableView dequeueReusableCellWithIdentifier:@"XXXXXX"];
来获取出来注册(其实还未注册)的cell
为nil
而导致崩溃。子类的伪代码调用顺序如下:/// 子类代码逻辑顺序 - (void)viewDidLoad { /// ①:子类调用父类的viewDidLoad方法,而父类主要是创建tableView以及强行布局子控件,从而导致tableView刷新,这样就会去走tableView的数据源方法 [super viewDidLoad]; /// ③:注册cell [self.tableView mh_registerNibCell:MHMainFrameTableViewCell.class]; } /// 返回自定义的cell - (UITableViewCell *)tableView:(UITableView *)tableView dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath{ // ②:父类的tableView的数据源方法的获取cell是通过注册cell的identifier来获取cell,然而此时子类并未注册cell,所以取出来的cell = nil而引发Crash return [tableView dequeueReusableCellWithIdentifier:@"MHMainFrameTableViewCell"]; }
当然,笔者平常开发都是通过纯代码来创建
Cell
的,极少使用到通过注册Cell
的方式(PS:个人编码习惯问题而已)。一般笔者的做法都会在新建的Cell
里面暴露一个获取创建好的Cell
的方法:+ (instancetype)cellWithTableView:(UITableView *)tableView
。代码实现如下:+ (instancetype)cellWithTableView:(UITableView *)tableView{ static NSString *ID = @"LiveRoomCell"; MHMainFrameTableViewCell *cell = [tableView dequeueReusableCellWithIdentifier:ID]; if (!cell) { cell = [self mh_viewFromXib]; cell.selectionStyle = UITableViewCellSelectionStyleNone; } return cell; }
所以起初笔者在调试这个
BUG
的时候,我也是一脸懵逼,因为我这里完美运行,而同事那里就蹦擦拉卡。后面才发现就是上面的伪代码逻辑②处获取的cell
为nil
导致的,而如果②采用笔者的获取cell
的方法,是绝逼不会有问题的。但是考虑到同事是比较偏向于通过UITableView+FDTemplateLayoutCell来自动计算cell
高度并缓存cell
高度的方式开发,然而这框架的使用前提就是必须通过为Cell
注册一个identifier
的方式。
所以笔者为了兼容同事的开发习惯,最终的做法是在MHTableViewController
中不使用Masonry
来布局tableView
,也不强制刷新(layoutIfNeeded)
视图控制器的子控件。而是直接指定tableView
的frame
,即:UITableView *tableView = [[UITableView alloc] initWithFrame:[UIScreen mainScreen].bounds style:self.viewModel.style];
。如果子类想要修改tableView
的尺寸,再使用Masonry
来布局即可。所以,这就是最终的做法...
当然还有MHTableViewController
还有许多逻辑细节处理,这里就不在过多赘述,更多内容请参考Demo中的MHTableViewController
设计。
Q&A
Q:项目中若同时集成 YYCategories
和 ReactiveCocoa
,使用@weakify(self)
和@strongify(self);
将会报Ambiguous expansion of macro weakify
和Ambiguous expansion of macro strongify
的警告。
A:由于 YYCategories
和 ReactiveCocoa
都定义了weakify
和strongify
引起的。解决办法如下:
知识点:怎样去除Xcode中的警告️
Q: 在Xcode 9.0上,ReactiveCocoa(2.5)
报Unknown warning group '-Wreceiver-is-weak', ignored
的警告。
A:RACObserve
定义如下:
#define RACObserve(TARGET, KEYPATH) \
({ \
_Pragma("clang diagnostic push") \
_Pragma("clang diagnostic ignored \"-Wreceiver-is-weak\"") \
__weak id target_ = (TARGET); \
[target_ rac_valuesForKeyPath:@keypath(TARGET, KEYPATH) observer:self]; \
_Pragma("clang diagnostic pop") \
})
在之前的Xcode中如果消息接受者是一个weak
对象,clang
编译器会报receiver-is-weak
警告,所以加了这段push&pop
,最新(iOS 11)的clang
已经把这个警告给移除,所以没必要加push&pop
了。
解决办法:修改Podfile
文件,将 pod 'ReactiveCocoa' ,'2.5'
改成如下
pod 'ReactiveCocoa', :git => 'https://github.com/zhao0/ReactiveCocoa.git', :tag => '2.5.2'
该方法原文参照:简书App适配iOS 11
Q:在Xcode 9.0上报 error: Illegal Configuration: Safe Area Layout Guide before iOS 9.0
错误。
A:SafeArea的概念是在iOS 9.0
以后才支持,所以只需要设置项目支持的版本:设置Deployment Target
和iOS Deployment Target
为9.0以上即可。
总结
本篇主要介绍了笔者在使用MVVM + RAC + ViewModel-Based Navigation
来搭建微信基本架构过程中的一点见解,其更深次的实践还需要各位小伙伴去自行体会,建议结合笔者文末提供的Demo
以及雷纯锋大神开源的MVVMReactiveCocoa来实践。
当然实践过程如人饮水,冷暖自知,多多重复,百炼成钢。希望小伙伴通过阅读这篇文章,能对MVVM + RAC + ViewModel-Based Navigation
的使用有一定基本的了解和使用,不一定要求完全去掌握它,这仅仅是我们众多开发模式的一个参考罢了,最主要的还是编程思想和细节处理。显然你也可以将其运用到MVC
设计模式中去,比如代码规范
、文件目录
、BaseClass
等等。使得MVVM
真正做到从群众(MVC
)中来,到群众(MVC
)中去。
或许还有许多细小逻辑和细小Bug需要我们去优化和处理,当然这便是此篇文章的存在的意义:集众人之智,成众人之事。
未完...待续...(PS:点关注,不迷路,笔者带你上高速)
考虑到文章篇幅过长影响阅读性,讲述其中技术的拓展性和全面性。笔者在接下来的时间内,会陆续将在开发WeChat
中的好用的技术以及细节处理分享出来,希望提供大家一个参考,并且可以运用到自己的实际的项目中去。主要是关于以下几个问题的解释和分析,还请小伙伴移步续篇👉iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发(二)
- 项目中的整体服务(Service)层解析。
- 项目中的网络(Network)层解析。
- 项目中如何快速搭建类似
发现
、我的
、设置
、...等界面解析。 - 如何利用该设计模式搭建
游客模式
(PS: 微信是登录模式的架构)的架构。 - 搭建
Debug
调试工具。
期待
- 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
- 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
- GitHub地址:https://github.com/CoderMikeHe
- 源码地址:WeChat