转载

iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发

前言

  • 由于最近两个多月,笔者正和小伙伴们忙于对公司新项目的开发,笔者主要负责项目整体架构的搭建以及功能模块的分工。首先,该项目采用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

代码结构

1.结构

iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发

2.说明

  • 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文件。

3.细节

  • 代码结构完全按照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)里面,但是只创建文件,而不创建文件夹。只要保证不超过三层目录即可。即如下图所示:

iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发

第三方框架

第三方框架想必对与小伙伴在熟悉不过了,其作用简而言之就是:辅助。让我们更专注于产品的业务逻辑开发,而不是某个功能点开发。这里简单介绍一下此次搭建微信(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 缓存框架,提供内存缓存和磁盘缓存。

  • 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)services params:(NSDictionary *)params;

/// The `services` parameter in `-initWithServices:params:` method.
@property (nonatomic, readonly, strong) id 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/// 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/// 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/// 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);
}];

其实,正常情况下完全没问题,但是MHTableViewController子类化后,在子类中设置了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。

iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发

UITableView崩溃.png

如果开启全局断点,那么会崩溃定位到[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的警告。

iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发

weakify&strongify警告.png

A:由于 YYCategories 和 ReactiveCocoa都定义了weakify和strongify引起的。解决办法如下:

iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发

weakify&strongify警告解决.png

知识点:怎样去除Xcode中的警告️



Q: 在Xcode 9.0上,ReactiveCocoa(2.5)报Unknown warning group '-Wreceiver-is-weak', ignored的警告。

iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发

Wreceiver-is-weak警告.png

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错误。

iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发

SafeAreaLayoutGuide.png

A:SafeArea的概念是在iOS 9.0以后才支持,所以只需要设置项目支持的版本:设置Deployment Target和iOS Deployment Target为9.0以上即可。

iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发

SafeAreaLayoutGuide解决①.png

iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发

SafeAreaLayoutGuide解决②.png



未完...待续...(PS:点关注,不迷路,笔者带你上高速)

  • 项目中的服务(Service)层解析(待续...)。

  • 项目中的网络(Network)层解析(待续...)。

  • 项目中如何快速搭建类似发现、我的、设置、...等界面解析(待续...)。

  • 如何利用该设计模式搭建游客模式(PS: 微信是登录模式的架构)的架构(待续...)。

期待

  1. 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议

正文到此结束
Loading...