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

前言
  • 由于最近两个多月,笔者正和小伙伴们忙于对公司新项目的开发,笔者主要负责项目整体架构的搭建以及功能模块的分工。首先,该项目采用MVVM + RAC + ViewModel-Based Navigation的设计模式,其次,尝试利用ViewModel-Based来实现导航(push/poppresent/dismiss)操作。最后,该项目在经过两个月的埋头苦干,也于近期成功上架AppStore【轻空-母婴二手用品寄售平台】。考虑到公司项目文件的保密性,这里笔者绝不会共享源码,而是采用笔者公司项目的同一套架构,来一步一步实现微信整体架构功能的开发。其目的就是让大家更加深沉次的领会 MVVM设计模式,以及利用ViewModel-Based来实现导航(push/poppresent/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. 结构

    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.title
    RAC(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阅读 211,884评论 6 492
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,347评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,435评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,509评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,611评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,837评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,987评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,730评论 0 267
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,194评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,525评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,664评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,334评论 4 330
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,944评论 3 313
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,764评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,997评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,389评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,554评论 2 349

推荐阅读更多精彩内容