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

前言
代码结构
  1. 结构

    CodeStructure.png

  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:存放资源文件,例如:图片,DataSQL文件。
  3. 细节

    • 代码结构完全按照MVVM来设计命名,实际上MVVMV应该包括视图控制器(ViewController)视图(View),这里只是将其单独分开,以便于更好的阅读和开发。
    • 必须强调文件夹的命名,这里笔者是按照主功能模块来命名,相信大家可以很清楚的看到ViewViewControllerViewModel三个文件夹里面的子模块文件夹都是一样的。而后期若在设计子文件夹的时候,参照这种方式来创建文件夹,那么大家会发现,你的代码目录会非常非常的整齐漂亮,同时方便后期维护和其他开发人员阅读代码,何乐而不为呢。
    • 同时强调一下自定义的视图控制器和视图模型的命名,理论上,一个视图控制器配备一个视图模型,所以笔者这里只是将视图控制的名字的ViewController替换成ViewModel即为配备的视图模型的名字:例如:视图控制器的名字为MHMainFrameViewController,则视图模型的名字为MHMainFrameViewModel。这样整个项目开发下来,你会发现ViewControllerViewModel文件下的文件都是对称的。
    • 目录层级不能超过三层。因为层级越深,越不易查找,且不易阅读。这里就以我的(Profile)为例,我的(Profile)界面有一个用户信息(UserInfo)子模块,用户信息(UserInfo)里面有一个更多(MoreInfo)子模块,更多(MoreInfo)模块当然也有子模块等等。如果这样划分,必然会导致目录结构很深,所以为了避免其发生,就尽量限制在三层即可,正所谓事不过三嘛,所谓三层目录可想而知,就是ViewController - Profile - UserInfo这三层便是,那么我们就可将更多(MoreInfo)模块与用户信息(UserInfo)并列即可,当然你也可以将更多(MoreInfo)模块的写在用户信息(UserInfo)里面,但是只创建文件,而不创建文件夹。只要保证不超过三层目录即可。即如下图所示:
ProfileCodeStructure.png
第三方框架

第三方框架想必对与小伙伴在熟悉不过了,其作用简而言之就是:辅助。让我们更专注于产品的业务逻辑开发,而不是某个功能点开发。这里简单介绍一下此次搭建微信(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的存在在所难免,但是它在项目中的作用是举足轻重的,简直神一样的存在。笔者这里主要详述ModelViewControllerViewModel中的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.hAPI的实现,内部封装了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

    这里笔者讲讲shouldDisableWebViewTitleshouldDisableWebViewClose这两个属性的作用以及使用场景。
    shouldDisableWebViewTitle: 是否取消导航栏的title等于webViewtitle。默认做法是MHWebViewController及其子类的导航栏titleWebViewtitle,而不是MHViewModeltitle属性。即控制器通过KVO的形式监听WKWebViewtitle属性,从而设置导航栏的titleself.navigationItem.title = self.webView.title。但是可能有几个H5界面想要设置导航栏的titleMHViewModeltitle属性,正所谓需求拉动生成,所以就产生了该属性。
    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中显示和隐藏导航栏底部细线的方法,一般这两个方法都是成对出现的,在ViewControllerviewWillAppear: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的导航栏返回按钮的titleA.title 。当然如果考虑到A.title的文字很长,那么需要自定义B的导航栏返回按钮的title< XXX。(大家没绕晕吧...)。这种自定义的做法需要结合MHViewModelbackTitle属性。详见代码如下:

      /// 能拦截所有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当然还有一些其他使命,比如统一设置UINavigationBarUIBarButtonItem的主题。这里就不一一阐述了,详见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文件,笔者这里讲讲根据MHViewModeltitle的属性设置导航栏title的细节,代码和细节处理如下所述:

    /// set navgation title// CoderMikeHe Fixed: 这里只是单纯设置导航栏的title。 不然以免self.title同时设置了navigatiItem.title, 同时又设置了tabBarItem.titleRAC(self.navigationItem , title) = RACObserve(self, viewModel.title);

    MHWebViewController是整个项目中所有需要显示WebView(WKWebView)的自定义的视图控制器的基类。其内部添加了一个全屏的WKWebView作为视图控制器View的子控件,主要目的是为了加载一些网页链接以及本地H5,开发中只需要直接使用MHWebViewController即可,很少需要将其子类化。通过绑定MHWebViewModelrequest属性来加载指定的网页,只要你能熟练使用WkWebView即可,其他的细节问题比如下拉刷新网页、WKWebView自适应屏幕、点击网页链接跳转处理,以及多次跳转网页后的导航栏关闭按钮的事件处理等... 请参考MHWebViewController.mMHWebViewController.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中的MHTabBarControllerMHTabBar即可。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的属性,依次来达到产品的需求。在此可见MVVMVM(视图模型)的重要性。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);}];

    其实,正常情况下完全没问题,但是MHTableViewController子类化后,在子类中设置了tableViewcontentInset属性,然而tableViewcontentOffset始终是(0,0),非常的神奇,到目前为止笔者也不知其原因(PS:若知道的大神, 请说一声哦),这样就导致了笔者一个需求上的Bug,就是笔者项目中首页是个商品列表,当你向下滑动到一定距离,屏幕右下角处会出现一个能够点击滚动到顶部的按钮,点击向上按钮就可以滚动到顶部即可。实现过程无非就是监听按钮的点击方法,实现[self.tableView setContentOffset:CGPointMake(0, 0) animated:YES];即可(理论上)。但是如果采用Masonry布局,就会出现点击向上按钮,你怎么也滚动不到顶部去,感觉tableView抽风了。当然,大家可以利用笔者提供的MHDevelopExample_Objective_CMVVM那块的内容进行复现或调试。
    笔者采取的解决办法是:笔者首先觉得可能tableView还未布局好而导致的,所以在利用Masonry布局tableView时,在MHTableViewController中强制布局了子控件,即调用[self.view layoutIfNeeded];,结果也很神奇,就可以实现点击向上按钮,能滚动到顶部了。
    但是...BUG还是出现了。如果MHTableViewModeldataSource的数据不是通过- (RACSignal *)requestRemoteDataSignalWithPage:(NSUInteger)page来获取的网络数据,而是在- (void)initialize中就初始化的死数据,例如发现模块页面中cell的数据源。当我们的Cellxib创建,且一般开发中会在MHTableViewController的子类中的-(void)viewDidLoad里面注册tableViewCell。切记:Bug复现条件必须是:TableViewModeldataSource是必须死(本地)数据,而非网络数据,并且是Cell是用tableView注册来获取的,缺一不可。这样会导致如下图所示的Bug。

    UITableView崩溃.png

    如果开启全局断点,那么会崩溃定位到[self.view layoutIfNeeded]的位置,由于强制布局(layoutIfNeeded)视图控制器的子控件,那么会导致tableView提前刷新(reloadData)其数据源的方法,而此时TableViewModeldataSource的数据又是本地数据,一开始是会有值,从而会调用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"];来获取出来注册(其实还未注册)的cellnil而导致崩溃。子类的伪代码调用顺序如下:

      /// 子类代码逻辑顺序  - (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的时候,我也是一脸懵逼,因为我这里完美运行,而同事那里就蹦擦拉卡。后面才发现就是上面的伪代码逻辑②处获取的cellnil导致的,而如果②采用笔者的获取cell的方法,是绝逼不会有问题的。但是考虑到同事是比较偏向于通过UITableView+FDTemplateLayoutCell来自动计算cell高度并缓存cell高度的方式开发,然而这框架的使用前提就是必须通过为Cell注册一个identifier的方式。
    所以笔者为了兼容同事的开发习惯,最终的做法是在MHTableViewController中不使用Masonry来布局tableView,也不强制刷新(layoutIfNeeded)视图控制器的子控件。而是直接指定tableViewframe,即:UITableView *tableView = [[UITableView alloc] initWithFrame:[UIScreen mainScreen].bounds style:self.viewModel.style];。如果子类想要修改tableView的尺寸,再使用Masonry来布局即可。所以,这就是最终的做法...
    当然还有MHTableViewController还有许多逻辑细节处理,这里就不在过多赘述,更多内容请参考Demo中的MHTableViewController设计。

Q&A

Q:项目中若同时集成YYCategoriesReactiveCocoa,使用@weakify(self)@strongify(self);将会报Ambiguous expansion of macro weakifyAmbiguous expansion of macro strongify的警告。

weakify&strongify警告.png

A:由于YYCategoriesReactiveCocoa都定义了weakifystrongify引起的。解决办法如下:

weakify&strongify警告解决.png

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


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

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

SafeAreaLayoutGuide.png

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

SafeAreaLayoutGuide解决①.png

SafeAreaLayoutGuide解决②.png

总结

本篇主要介绍了笔者在使用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调试工具。
期待
  1. 文章若对您有些许帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
  2. 针对文章所述内容,阅读期间任何疑问;请在文章底部评论指出,我会火速解决和修正问题。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源码地址:WeChat
参考链接
最后编辑于 :
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 224,728评论 6赞 522
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 96,220评论 3赞 402
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 171,936评论 0赞 366
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 60,976评论 1赞 300
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 69,981评论 6赞 399
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 53,468评论 1赞 314
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 41,843评论 3赞 428
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 40,817评论 0赞 279
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 47,353评论 1赞 324
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 39,384评论 3赞 346
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 41,510评论 1赞 354
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 37,113评论 5赞 350
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 42,833评论 3赞 338
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 33,290评论 0赞 25
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 34,419评论 1赞 275
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 50,055评论 3赞 381
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 46,577评论 2赞 365

推荐阅读更多精彩内容