iOS 基于MVC设计模式的基类设计

前言
  • 最近有很多小伙伴,看了笔者这篇iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发(一)文章后反馈给笔者很多优质性的建议和意见,当然这跟当年笔者写这篇文章的初衷如出一辙,其根本目的就是拿出来和大家交流分享以及学习知道,希望可以抛砖引玉,取长补短,共同进步。再此,非常感谢大家的积极反馈和批评指导,给了笔者继续写文章的动力。

  • iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发(一)这篇文章主要讲的是基于MVVM设计模式的基类设计,通过基类提供的API和属性来解决当前产品开发中一些常用的业务逻辑场景切换,以及快速搭建出项目的基本骨架...等等。但是对于刚初学MVVM设计模式的开发者并不是很友好,可能会导致看完文章一脸懵逼的下场,然后看完后又不能将其运用到实际项目中去,当然会觉得大失所望呀。当然,这里笔者建议初学者,可以先看看笔者之前写的有关于MVVM设计模式学习的文章,循序渐进,方得始终,有了一定的基础再来阅读和学习这篇文章。

  • 当然,也有很多重度使用基于MVC设计模式开发的以及初学iOS的小伙伴私信笔者,希望我写一篇关于基于MVC设计模式的常用基(套)类(路)设计,笔者深感鸭梨山大,并在业余时间写了一套笔者开发中常用的基于MVC设计模式的基类设计套路,才有了本篇文章的诞生。当然还是建议头铁的小伙伴先去看看iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发(一)这篇文章,其BaseClass的设计说明笔者写的更加详细,如此一来,大家了解了使用场景后,再反过头来在看本篇文章,你就会觉得So Easy~。最后希望大家看了以后有所收获,学以致用。文章仅供大家参考,若有不妥之处,还望不吝赐教,欢迎批评指正。

概述
  • 这里笔者还是以微信为例,利用笔者常用的基于MVC设计模式的开发套路开发出微信的基本骨架。当然这里需要特别申明:以下内容都是笔者在日常开发中比较常用的基于MVC设计模式的开发套路,希望大家借鉴学习,也欢迎大家说说自己的基于MVC设计模式的开发套路,也让笔者借鉴学习学习。
  • 本篇文章内容主要侧重基类的设计和使用,当然笔者会详细的介绍各个基类的头文件暴露出来的属性和API的使用以及具体的使用场景。首先,基类的出现是为了聚合大量共有的常用业务逻辑,这样能极大程度的减少开发者冗余代码的产生,且让开发者更加专注于自身模块的开发。其次,基类提供API让其子类去重写,这样一定程度上保证了开发规范,让各个开发者写出易读、易懂的代码。
  • 此次,笔者设计的基类依然采用的是继承的方式来开发微信的基本骨架,当然,很多小伙伴会问,为何不用协议的方式?笔者个人认为,协议过于分散,而继承则比较单一。萝卜白菜,各有所爱,大家完全可以参考完笔者的基类设计后,可以自行DIY,写出自己习惯的套路来即可。
代码结构
  1. 结构


    CodeArchitecture.png
  2. 说明
    • Utils:存放工具类和管理类。例如:分类Category...
    • Vendor:存放第三方框架。例如:MJRefresh...
    • Macros:存放常量。例如:宏(#define)定义常量,const常量,枚举(NS_ENUM)常量,inline函数,URL路径常量。
    • Resource:存放资源文件。例如:图片,DataSQLPlistJson等文件。
    • Other: 公有的ModelViewController。例如:MHTextField...
    • BaseClass 全局基类ViewModelViewController。用于继承。
BaseClass

关于BaseClass的设计,笔者主要从ModelViewViewController来设计,但是关于ModelView的基类,这里建议大家移步iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发(一)这篇文章关于ModelView的基类的解释说明,这里笔者就不再赘述,这里着重讲的是ViewController的基类设计和使用场景。基类文件结构如下:

BaseClass.png

通过上图👆大家很容易看出ViewController的基类在命名跟系统命名类似,无非是把系统UI改成CMH即可,同时这样也体现出很大的场景使用性和可读性。CMHNavigationControllerCMHTabBarController的设计和使用跟iOS 基于MVVM + RAC + ViewModel-Based Navigation的微信开发(一)这篇文章的MHNavigationControllerMHTabBarController的设计和使用如出一辙,这里也就不再赘述了。本篇文章笔者将只详述:CMHViewControllerCMHTableViewControllerCMHCollectionViewControllerCMHWebViewController这四个基类的设计说明和使用场景,以及配备大量Example来解释说明基类暴露出来的属性和API社会我Mike,人狠话不多,基类的使用,笔者一一道来。

CMHViewController

CMHViewController 是整个项目中所有自定义的视图控制器(ViewController)的基类,继承于UIViewControllerCMHViewController主要任务是为其子类提供一些基础的配置和API,方便子类去配置和重写,来满足不同的业务场景。详情请查看CMHViewController.h文件内容。CMHViewController.h的使用示例都放在MainFrame文件夹中。划重点 开发者只需要在其子类重写init方法,然后配置一些属性即可,代码如下:

/// 重写init方法,配置你想要的属性
- (instancetype)init
{
    self = [super init];
    if (self) {
        
        /// (是否取消掉当前控制器左滑pop到上一层的功能(栈底控制器无效),NO: 不取消<默认>,YES: 禁止侧滑左侧返回)
        self.interactivePopDisabled = YES;
        
        /// 禁止侧滑场景:
        /// 1. 主要是防止一些当前控制器的手势与侧滑手势冲突,比如图片浏览器,图片贴纸 ...等
        /// 2. 不希望侧滑返回上一层,比如点击右上角返回按钮,返回到根视图
    }
    return self;
}

CMHViewController.h属性的使用:

/// FDFullscreenPopGesture
/// (是否取消掉左滑(侧滑)pop到上一层的功能(栈底控制器无效),默认为NO,不取消)
@property (nonatomic, readwrite, assign) BOOL interactivePopDisabled;

该属性控制当前控制器(PS:当前控制器是被Push进来的)是否取消掉侧滑Pop的功能,注意栈底控制器无效。这个侧滑返回的功能是iOS开发中比较常见的,且iOS系统在iOS 7以后也自带这种边缘触发手势UIScreenEdgePanGestureRecognizer并且其只有一个属性叫edges,用来设置它的触发边缘(上、下、左、右、全部),但是只支持侧滑屏幕边缘才有效。而笔者的侧滑是全屏的,当然该功能的实现则得益于FDFullscreenPopGesture。其示例代码请参照:MainFrame/Example00


/// FDFullscreenPopGesture
/// 是否隐藏该控制器的导航栏 默认是不隐藏 (default is NO)
@property (nonatomic, readwrite, assign) BOOL prefersNavigationBarHidden;

该属性控制当前控制器的导航栏的显示或隐藏功能。正常情况下我们常见的显示或隐藏导航栏代码无非是下面👇的代码:

- (void)viewWillAppear:(BOOL)animated{
    [super viewWillAppear:animated];
    /// 隐藏导航栏
    [self.navigationController setNavigationBarHidden:YES animated:YES];
}

- (void)viewWillDisappear:(BOOL)animated{
    [super viewWillAppear:animated];
    /// 显示导航栏
    [self.navigationController setNavigationBarHidden:NO animated:YES];
}

这里开发者只需要设置prefersNavigationBarHiddenYES or NO就可以控制显示或隐藏导航栏。当然该功能的实现则也得益于FDFullscreenPopGesture。其示例代码请参照:MainFrame/Example01


/// 是否隐藏该控制器的导航栏底部的分割线 默认不隐藏 (NO)
@property (nonatomic, readwrite, assign) BOOL prefersNavigationBarBottomLineHidden;

该属性控制当前控制器导航栏底部的分割线的显示或隐藏,首先系统的导航栏底部本身自带一个高度为.5f的分割线,但是平常开发我们大多数会自定义这条分割线的颜色,来满足产品样式开发的统一基调。关于这部分的定义和显示隐藏逻辑,请参照CMHNavigationController.h/m文件的实现。其示例代码请参照:MainFrame/Example02


/// IQKeyboardManager
/// 是否让IQKeyboardManager的管理键盘的事件 默认是YES(键盘管理)
@property (nonatomic, readwrite, assign) BOOL keyboardEnable;
/// 是否键盘弹起的时候,点击其他区域键盘掉下 默认是 YES
@property (nonatomic, readwrite, assign) BOOL shouldResignOnTouchOutside;
/// To set keyboard distance from textField. can't be less than zero. Default is 10.0.
/// 键盘顶部距离当前响应的textField的底部的距离,默认是10.0f,前提得 `keyboardEnable = YES` 且数值不得小于 0。
@property (nonatomic, readwrite, assign) CGFloat keyboardDistanceFromTextField;

这些属性都是用来控制键盘的相关的事件处理,其底层实现得益于IQKeyboardManager,这里笔者也是抽取了其比较常用的属性到基类,当然IQKeyboardManager更多更牛逼的功能,开发者自行探索和实现哈。其示例代码请参照:MainFrame/Example03


/// 截图(Push/Pop Present/Dismiss 过度过程中的缩略图)主要用在过渡动画里面
@property (nonatomic, readwrite, strong) UIView *snapshot;

该属性主要是用来做在(Push/Pop Present/Dismiss)的过渡动画。关于过渡动画的使用和说明请参照WWDC 2013 Session笔记 - iOS7中的ViewController切换。这里笔者也提供了两套过渡动画的使用,当然有关过渡动画的问题也可以问我哈,这里主要突出基类的设计,笔者就不详细介绍具体的实现内容了,其示例代码请参照:MainFrame/Example04MainFrame/Example05


/** should request data when viewController videwDidLoad . default is YES*/
/** 是否需要在控制器viewDidLoad后调用`requestRemoteData` default is YES*/
@property (nonatomic, readwrite, assign) BOOL shouldRequestRemoteDataOnViewDidLoad;

该属性控制是否在当前控制器viewDidLoad方法调用后,是否需要自动调用requestRemoteData方法(PS:该API下面会说到)。默认情况是YES,而当前控制器只需要重写基类的- requestRemoteData方法即可,无需再在当前控制器viewDidLoad方法调用后手动调用,如果设置为NO,那开发者需要手动调用- requestRemoteData方法,具体看使用场景。其示例代码请参照:MainFrame/Example06


/// The callback block. 当Push/Present时,通过block反向传值
@property (nonatomic, readwrite, copy) void (^callback)(id);

该属性主要用于反向传值,这里使用block来代替delegate,增加其简洁性。反向传值的场景有很多,具体看实际使用场景。

当然正向传值一般采用- (instancetype)initWithParams:(NSDictionary *)params;创建控制器并向其传值(param),以及结合以下👇常用的key来达到获取传过来的的值(param)。 其示例代码请参照:MainFrame/Example07

/// The base map of 'params'
/// The `params` parameter in `-initWithParams:` method.
/// Key-Values's key
/// 传递唯一ID的key:例如:商品id 用户id...
FOUNDATION_EXTERN NSString *const CMHViewControllerIDKey;
/// 传递数据模型的key:例如 商品模型的传递 用户模型的传递...
FOUNDATION_EXTERN NSString *const CMHViewControllerUtilKey;
/// 传递webView Request的key:例如 webView request...
FOUNDATION_EXTERN NSString *const CMHViewControllerRequestKey;

CMHViewController.hAPI的使用:

/// ------------ Method ------------
/// Initialization method. This is the preferred way to create a new Controller.
///
/// params   - The parameters to be passed to Controller. can be nil
///
/// Returns a new Controller.
- (instancetype)initWithParams:(NSDictionary *)params;

/// 基础配置 (PS:子类可以重写,但不需要在ViewDidLoad中手动调用,但是子类重写必须要调用 [super configure])
- (void)configure;

/// 请求远程数据
/// sub class can override , 但不需要在ViewDidLoad中手动调用 ,依赖`shouldRequestRemoteDataOnViewDidLoad = YES` 且不用调用 super, 直接重写覆盖
- (void)requestRemoteData;

/// fetch the local data
/// sub class can override ,且不用调用 super, 直接重写覆盖
/// Returns a local data.
- (id)fetchLocalData;

这几个API的使用和说明完全可以看注释, 但是这几个API的设计的作用,主要是规范子类API。子类完全可以根据实际的业务逻辑,去重写和覆盖基类的API,比如:当前控制器需要获取远程数据,则只需要在当前控制器重写- (void)requestRemoteData;方法,如果当前控制器不需要获取远程数据,你就不要去重写- (void)requestRemoteData;方法呗。当然写了也不会怀孕。。其示例代码请参照:MainFrame/Example06

CMHTableViewController

CMHTableViewController 是整个项目中所有需要显示列表(UITableView)的自定义的视图控制器(ViewController)的基类,继承于CMHViewControllerCMHTableViewController主要作用是提供了一个全屏大小的UITableView,且懒加载一个数据源dataSource,提供了一系列的属性和API,来满足现实开发中的常用场景,且使用度非常之高,比如:控制是否支持下拉刷新和上拉加载,以及暴露下拉刷新事件和上拉加载事件,子类只需配置相关的属性和重写相关的API即可满足,省去了大量的冗余代码,方便开发者专注于模块功能的开发。详情请查看CMHTableViewController.h文件内容。CMHTableViewController.h的使用例子都放在Contacts文件夹中。

CMHTableViewController.h属性的使用:

/// The table view for tableView controller. <自带全屏tableView,子类可以重新布局其frame>
/// tableView
@property (nonatomic, readonly, weak) UITableView *tableView;

/// The data source of table view <数据源懒加载>
@property (nonatomic, readonly, strong) NSMutableArray *dataSource;

/// 当前页 defalut is 1
@property (nonatomic, readwrite, assign) NSUInteger page;
/// 每一页的数据 defalut is 20
@property (nonatomic, readwrite, assign) NSUInteger perPage;

首先基类内部提供一个全屏的tableView,子类完全可以根据自身的业务场景,定制该tableView,比如设置其大小,改变其背景色...等。同时基类懒加载了一个dataSource,给子类使用。 pageperPage就具体按照自己后台规定即可,这里就无需多言了哈。其示例代码请参照:Contacts文件夹任意一个示例。


/// `tableView` 的内容缩进,default is UIEdgeInsetsMake(64或者88,0,0,0),you can override it
@property (nonatomic, readonly, assign) UIEdgeInsets contentInset;

/// tableView‘s style defalut is UITableViewStylePlain , 只适合 UITableView 有效
@property (nonatomic, readwrite, assign) UITableViewStyle style;

contentInset主要影响tableView.contentInset罢了,这里是一个只读(readonly)属性,所以子类需要重写其get方法即可。默认值是:如果是iPhone X ,则为UIEdgeInsetsMake(88,0,0,0),若不是,则为UIEdgeInsetsMake(64,0,0,0)。其示例代码请参照:Contacts/Example12

style则主要影响的是tableView的样式,即:UITableView *tableView = [[UITableView alloc] initWithFrame:[UIScreen mainScreen].bounds style:self.style];这里也笔者不过多介绍了。其示例代码请参照:Contacts/Example11


/// 是否数据是多段 (It's effect tableView's dataSource 'numberOfSectionsInTableView:') defalut is NO,但是跟组头组尾数据没任何关联
@property (nonatomic, readwrite, assign) BOOL shouldMultiSections;

shouldMultiSections主要影响tableViewUITableViewDataSource代理方法,即:- (NSInteger)numberOfSectionsInTableView:(UITableView *)tableView。但是这里必须强调的是:它跟组头组尾的数据显示没有关联,也就是他只能保证UITableViewCell是多组的,所以dataSource里面的每一个元素都是一个数组(NSArray)。比如微信的 :发现界面,我的界面,设置界面..。等。其示例代码请参照:Contacts/Example15


/// 需要支持下来刷新 defalut is NO
@property (nonatomic, readwrite, assign) BOOL shouldPullDownToRefresh;
/// 是否默认开启自动刷新, YES : 系统会自动调用`tableViewDidTriggerHeaderRefresh` NO : 开发人员可以在适当时机手动调用 `tableViewDidTriggerHeaderRefresh`
@property (nonatomic, readwrite, assign) BOOL shouldBeginRefreshing;
/// 需要支持上拉加载 defalut is NO
@property (nonatomic, readwrite, assign) BOOL shouldPullUpToLoadMore;
/// 是否在上拉加载后的数据,dataSource.count < perPage 提示没有更多的数据.default is YES 否则 隐藏mi_footer 。 前提是` shouldMultiSections = NO `才有效。
@property (nonatomic, readwrite, assign) BOOL shouldEndRefreshingWithNoMoreData;

上面👆这些属性都跟上拉加载,下拉刷新相关的,这里的刷新控件用的是MJRefresh。笔者在基类属性设计和命名也比较的直观易懂,这里大家完全可以自行参照注释去学习和使用。这里笔者就着重说说shouldEndRefreshingWithNoMoreData这个属性,首先该属性有效的前提是shouldMultiSections = NO,否则无效。其次,我们知道上拉加载后的请求的数据可能少于perPage,则就说明服务器已经没有更多数据了,就无需再去请求数据了,这样我们需要给用户友好提示了。关于这几个属性的使用示例代码请参照Contacts/Example13 ,Contacts/Example14 ,Contacts/Example16


CMHTableViewController.hAPI的使用:

/// sub class can override 且 不需要调用 [super ....] , 也可以直接调用不需要重写
/// reload tableView data , sub class can override , 等效于 [self.tableView reloadData]
- (void)reloadData;

/// dequeueReusableCell <复用cell> 子类需重写,无须调用 [super xxx]
- (UITableViewCell *)tableView:(UITableView *)tableView dequeueReusableCellWithIdentifier:(NSString *)identifier forIndexPath:(NSIndexPath *)indexPath;

/// configure cell with data <为cell配置模型 , 等效于 cell.model = object> 子类需重写,无须调用 [super configureCell:atIndexPath:withObject:]
- (void)configureCell:(UITableViewCell *)cell atIndexPath:(NSIndexPath *)indexPath withObject:(id)object;

/// 下拉刷新事件 子类需重写,无须调用 [super tableViewDidTriggerHeaderRefresh]
- (void)tableViewDidTriggerHeaderRefresh;
/// 上拉加载事件 子类需重写,无须调用 [super tableViewDidTriggerFooterRefresh]
- (void)tableViewDidTriggerFooterRefresh;
///brief 加载结束 这个方法  子类只需要在 `tableViewDidTriggerHeaderRefresh`和`tableViewDidTriggerFooterRefresh` 结束刷新状态的时候直接调用即可,不需要重写,当然如果不喜欢内部的处理逻辑,你直接重写即可
///discussion 加载结束后,通过参数reload来判断是否需要调用tableView的reloadData,判断isHeader来停止加载
///param isHeader   是否结束下拉加载(或者上拉加载)
///param reload     是否需要重载TabeleView
- (void)tableViewDidFinishTriggerHeader:(BOOL)isHeader reload:(BOOL)reload;

基类笔者提供以上👆几个API,主要涉及到界面的刷新复用Cell的创建Cell显示的模型配置下拉刷新的事件上拉加载的事件,以及结束刷新控件的刷新状态等方法,结合注释和示例程序,相信小伙伴会很快上手,强烈大家去看看CMHTableViewController.m的实现和注释,这样能更好的理解上述的属性和API使用场景。关于这几个API的使用示例代码请参照Contacts/Example13 ,Contacts/Example14 ,Contacts/Example15,Contacts/Example16

CMHCollectionViewController

CMHCollectionViewController 是整个项目中所有需要显示UICollectionView的自定义的视图控制器(ViewController)的基类,继承于CMHViewControllerCMHCollectionViewController主要作用是提供了一个全屏大小的UICollectionView,且懒加载一个数据源dataSource,提供了一系列的属性和API,来满足现实开发中的常用场景。这里CMHCollectionViewController的API和属性跟CMHTableViewController的属性和API设计的及其类似,且使用也类似,这里笔者就不再复述了。详情请查看CMHCollectionViewController.h文件内容。关于CMHCollectionViewController.h的使用例子都放在Discover文件夹中。当然这里着重讲的就是那个布局(collectionViewLayout)属性。

/// collectionView 的布局,默认是 `UICollectionViewFlowLayout`
@property (nonatomic, readwrite, strong) UICollectionViewLayout *collectionViewLayout;

首先,我们非常清楚UICollectionView最核心的内容也就是其布局,当然我们在创建UICollectionView的时候,即:- (instancetype)initWithFrame:(CGRect)frame collectionViewLayout:(UICollectionViewLayout *)layout,必须得传一个布局进去,否则就会Crash。所以,系统已经为我们提供了一个流水布局UICollectionViewFlowLayout给我们使用,这样已经完全可以解决大部分的使用场景。当然,一些场景需要自定义布局,来满足产品的需求开发,比如瀑布流布局,电影卡片布局...等等。这里笔者就提供了三套布局来证明该CMHCollectionViewController的可行性。只需要在子类里面重写init方法,并将自定义布局创建好,赋值给collectionViewLayout属性即可,示例核心代码如下:

/// 重写init方法,配置你想要的属性
- (instancetype)init
{
    self = [super init];
    if (self) {
        
        /// create collectionViewLayout
        CHTCollectionViewWaterfallLayout *layout = [[CHTCollectionViewWaterfallLayout alloc] init];
        layout.sectionInset = UIEdgeInsetsMake(10, 15, 10, 15);
        layout.headerHeight = 0;
        layout.footerHeight = 0;
        layout.minimumColumnSpacing = 10;
        layout.minimumInteritemSpacing = 10;
        
        self.collectionViewLayout = layout;
        
        self.perPage = 10;
        
        /// 支持上下拉加载和刷新
        self.shouldPullUpToLoadMore = YES;
        self.shouldPullDownToRefresh = YES;
        
    }
    return self;
}

关于自定义布局,大家可以自行百度哈,这里笔者着重讲的是CMHCollectionViewController的实用性和拓展性,当然,现实开发中CMHCollectionViewController的使用频率远远没有CMHTableViewController的使用度高,当然使用的最多布局还是UICollectionViewFlowLayout。笔者示例代码中用到的布局请参考:CHTCollectionViewWaterfallLayout(瀑布流布局) 、UICollectionViewLeftAlignedLayout(左对齐流水布局)、XLCardSwitchFlowLayout(电影卡片布局)。示例代码请参考:Discover/Example20 ,Discover/Example21 ,Discover/Example22,Discover/Example23

CMHWebViewController

CMHWebViewController 是整个项目中所有需要显示WKWebView的自定义的视图控制器(ViewController)的基类,继承于CMHViewControllerCMHWebViewController主要作用是提供了一个全屏大小的WKWebView,用来加载一些H5界面,当然笔者也提供了一系列的属性和API,来满足现实开发中的常用场景。详情请查看CMHWebViewController.h文件内容。关于CMHWebViewController.h的使用例子都放在Profile文件夹中。

/// webView
@property (nonatomic, weak, readonly) WKWebView *webView;
/// 内容缩进 (64,0,0,0)
@property (nonatomic, readonly, assign) UIEdgeInsets contentInset;

/// web url quest 如果localFile == YES , 则requestUrl 为本地路径 ; 反之,requestUrl为远程url str
@property (nonatomic, readwrite, copy) NSString *requestUrl;
/// 是否是本地文件 default is NO
@property (nonatomic , readwrite , assign , getter = isLocalFile) BOOL localFile;

/// 下拉刷新 defalut is NO
@property (nonatomic, readwrite, assign) BOOL shouldPullDownToRefresh;
/// 是否默认开启自动刷新, YES : 系统会自动调用下拉刷新事件。  NO : 开发人员手动调用需要手动拖拽 默认是YES
@property (nonatomic, readwrite, assign) BOOL shouldBeginRefreshing;

/// 是否取消导航栏的title等于webView的title。默认是不取消,default is NO
@property (nonatomic, readwrite, assign) BOOL shouldDisableWebViewTitle;

/// 是否取消关闭按钮。默认是不取消,default is NO
@property (nonatomic, readwrite, assign) BOOL shouldDisableWebViewClose;

/// messageHandlers: 就是你要注册的 JS 调用 OC 的方法名
@property (nonatomic , readwrite , copy) NSArray <NSString *> *messageHandlers;
/// 导航栏高度 默认是 系统导航栏的高度
@property (nonatomic , readwrite , assign) CGFloat navigationBarHeight;

contentInsetshouldPullDownToRefreshshouldBeginRefreshingCMHTableViewController使用场景一模一样,这里笔者就讲讲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:干着程序猿的活,抄着产品经理的心)。
requestUrllocalFilerequestUrlH5需要加载的地址,localFile则用来区分地址是否为网络地址本地地址网络地址的加载这里没什么好讲的,直接调用WKWebView- (nullable WKNavigation *)loadRequest:(NSURLRequest *)request;即可。但是加载本地地址的时候,则需要区分手机系统是否是iOS9.0以上的系统,这里讲讲,iOS9.0以下的版本,如果单纯的用- (nullable WKNavigation *)loadRequest:(NSURLRequest *)request;来加载本地地址是有问题的。具体问题如下:

当使用loadRequest来读取本地的HTML时,WKWebView是无法读取成功的,后台会出现如下的提示:
Could not create a sandbox extension for /
原因是WKWebView是不允许通过loadRequest的方法来加载本地根目录的HTML文件。
而在iOS9的SDK中加入了以下方法来加载本地的HTML文件:
[WKWebView loadFileURL:allowingReadAccessToURL:]
但是在iOS9以下的版本是没提供这个便利的方法的。以下为解决方案的思路,就是在iOS9以下版本时,先将本地HTML文件的数据copy到tmp目录中,然后再使用loadRequest来加载。但是如果在HTML中加入了其他资源文件,例如js,css,image等必须一同copy到temp中。这个是最蛋疼的事情了。

所以解决方案如下:

- (void)configure{
    [super configure];
    /// 容错处理
    if (MHStringIsNotEmpty(self.requestUrl) && !self.isLocalFile)  {    /// 网络
        //格式化含有中文的url
        self.requestUrl =  (NSString *)CFBridgingRelease(CFURLCreateStringByAddingPercentEscapes(kCFAllocatorDefault, (CFStringRef)self.requestUrl, (CFStringRef)@"!$&'()*+,-./:;=?@_~%#[]", nil, kCFStringEncodingUTF8));
        NSMutableURLRequest * request = [NSMutableURLRequest requestWithURL:[NSURL URLWithString:self.requestUrl]];
        /// 加载请求数据
        [self.webView loadRequest:request];
    }else if (MHStringIsNotEmpty(self.requestUrl) && self.isLocalFile){ /// 本地
        /// 本地分 ios9.0以下 和 ios9.0以上处理方式
        /// https://www.jianshu.com/p/ccb421c85b2e
        /// https://blog.csdn.net/xinshou_caizhu/article/details/72614584
        /// https://blog.csdn.net/wojiaoqiaoxiaoqiao/article/details/79876904
        NSURL *fileURL = [NSURL fileURLWithPath:self.requestUrl];
        if ([[UIDevice currentDevice].systemVersion floatValue] >= 9.0) {
            // iOS9. One year later things are OK.
            [self.webView loadFileURL:fileURL allowingReadAccessToURL:fileURL];
        } else {
            // iOS8. Things can be workaround-ed
            //   Brave people can do just this
            fileURL = [self _fileURLForBuggyWKWebView8:fileURL];
            NSURLRequest *request = [NSURLRequest requestWithURL:fileURL];
            [self.webView loadRequest:request];
        }
    }
}

/// 9.0以下将文件夹copy到tmp目录
- (NSURL *)_fileURLForBuggyWKWebView8:(NSURL *)fileURL {
    NSError *error = nil;
    if (!fileURL.fileURL || ![fileURL checkResourceIsReachableAndReturnError:&error]) {
        return nil;
    }
    NSFileManager *fileManager = [NSFileManager defaultManager];
    NSURL *temDirURL = [NSURL fileURLWithPath:NSTemporaryDirectory()];
    [fileManager createDirectoryAtURL:temDirURL withIntermediateDirectories:YES attributes:nil error:&error];
    NSURL *dstURL = [temDirURL URLByAppendingPathComponent:fileURL.lastPathComponent];
    [fileManager removeItemAtURL:dstURL error:&error];
    [fileManager copyItemAtURL:fileURL toURL:dstURL error:&error];
    return dstURL;
}

当然iOS9.0以下WKWebView的坑点还不止这些,有兴趣的童鞋可以看看下面👇的坑。

messageHandlers: 主要用来处理JS交互的,里面装着webView要注册的 JS 调用 OC 的方法名。JS原生之间的交互,想必大家并不陌生,这里笔者就简单的说说,这里是基于WKWebView提供的API来实现的。示例代码请参考:Profile/Example35.
OC 调用 JS : 利用系统提供的API,直接调用- (void)evaluateJavaScript:(NSString *)javaScriptString completionHandler:(void (^ _Nullable)(_Nullable id, NSError * _Nullable error))completionHandler;API即可。
JS 调用 OC :这种场景是开发中比较常见得需求,当然利用系统提供的API代理也是非常方便。主要分为以下两步:

/*! @abstract Adds a script message handler.
 @param scriptMessageHandler The message handler to add.
 @param name The name of the message handler.
 @discussion Adding a scriptMessageHandler adds a function
 window.webkit.messageHandlers.<name>.postMessage(<messageBody>) for all
 frames.
 */
/// 第一步:注册JS调用OC的方法名, name : 就是JS调用的方法名
- (void)addScriptMessageHandler:(id <WKScriptMessageHandler>)scriptMessageHandler name:(NSString *)name;

/*! @abstract Invoked when a script message is received from a webpage.
 @param userContentController The user content controller invoking the
 delegate method.
 @param message The script message received.
 */
/// 第二步:实现<WKScriptMessageHandler>协议中方法,其中message就是JS回调的值。
/// message.name : 就是你第一步注册的方法名,通过判断方法名,来处理不同的事件
/// message.body:就是JS给原生传的参数
- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message;

特别说明:使用- (void)addScriptMessageHandler:(id <WKScriptMessageHandler>)scriptMessageHandler name:(NSString *)name会导致循环引用,从而导致控制器释放不掉而导致内存泄漏,具体原因如下:

这里WKUserContentController对象的addScriptMessageHandler方法的scriptMessageHandler参数传入了将控制器本身(猜测addScriptMessageHandler将会对scriptMessageHandler参数传入的对象做强引用,这点开发文档没有说明),而控制器又强引用了webView,然后webView又强引用了configuration,configuration又强引用了WKUserContentController对象,所以导致了引用循环,从而导致控制器不被释放的问题.

具体解决方案,可以参照下面👇来实现,这里笔者就不过多赘述了:

具体代码实现如下:

/// CoderMikeHe Fixed Bug : 防止循环引用,以及重复添加handler
@interface WeakScriptMessageDelegate : NSObject<WKScriptMessageHandler>

@property (nonatomic, weak) id<WKScriptMessageHandler> scriptDelegate;

- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate;

@end

@implementation WeakScriptMessageDelegate

- (instancetype)initWithDelegate:(id<WKScriptMessageHandler>)scriptDelegate{
    self = [super init];
    if (self) {
        _scriptDelegate = scriptDelegate;
    }
    return self;
}

- (void)userContentController:(WKUserContentController *)userContentController didReceiveScriptMessage:(WKScriptMessage *)message{
    [self.scriptDelegate userContentController:userContentController didReceiveScriptMessage:message];
}

- (void)dealloc{
    MHDealloc;
}

@end

/// 在CMHWebViewController的使用
- (void)dealloc{
    MHDealloc;
    /// remove observer ,otherwise will crash
    [_webView stopLoading];
    /// CoderMikeHe Fixed Bug :移除掉JS调用OC的方法,否则循环引用
    for (NSString * name in _messageHandlers) {
        [_webView.configuration.userContentController removeScriptMessageHandlerForName:name];
    }
    
    [_webView stopLoading];
    _webView.scrollView.delegate = nil;
    _webView.navigationDelegate = nil;
    _webView.UIDelegate = nil;
    _webView = nil;
}
- (void)configure{
    [super configure];
    /// 注册 JS调用OC的方法
    for (NSString * name in self.messageHandlers) {
        [self.webView.configuration.userContentController addScriptMessageHandler:[[WeakScriptMessageDelegate alloc] initWithDelegate:self] name:name];
    }
}
总结

以上内容就是笔者在产品开发中,若使用MVC设计模式来开发产品的前提下,比较常用一套开发套路。首先,这只是笔者喜爱的开发套路,并不能满足所有开发者的业务场景,大家只需要把有用或有效的内容拿过来参照参照,且能够完美运用到自身项目的架构中去,这样笔者就非常欣慰了。其次,如果大家有许多好点子或建议,请及时反馈(评论or私信)给笔者,看能否加在基类里面,更好的去造福于广大初学的开发者。最后,整体项目的基类设计,无论在属性命名,还是API设计都有比较强的逻辑性和指向性,当然笔者能力有限,并没有设计成非常的高大上,而是非常的易读易懂,大家可以结合注释以及使用示例更好的去理解和使用,争取早日上手和拓展使用。

当然这里主要讲的基类的头文件的内容,具体的实现逻辑还是需要大家去基类的实现文件.m去学习和理解,当然大家主要看那些笔者用CoderMikeHe Fixed Bug标识的部分,大家也可以全局搜索CoderMikeHe Fixed Bug字段,这些修复Bug的内容,才是整篇基类的核心所在,当然,笔者依然不敢保证还是否有Bug的出现。有问题,及时交流即可。

最后讲讲关于开发者在自定义控制器时,基类继承的选取问题,建议如下。

  • 如果你自定义的控制器不需要显示tableViewcollectionViewwebView,只想一个简简单单控制器,那么就直接继承于CMHViewController即可,比如微信登录模块
  • 如果你自定义的控制器需要使用到tableView来展示列表数据,那么就直接继承于CMHTableViewController即可,比如微信发现模块
  • 如果你自定义的控制器需要collectionView来展示类似九宫格的数据,那么就直接继承于CMHCollectionViewController即可,比如自定义手机相册模块
  • 如果你自定义的控制器需要加载H5页面,那么就直接继承于CMHWebViewController即可或者没有H5交互的情况直接使用CMHWebViewController,比如微信使用帮助模块
  • 究竟继承哪个基类,请根据自身模块的使用场景,灵活选取即可。
期待
  1. 文章若对您有点帮助,请给个喜欢❤️,毕竟码字不易;若对您没啥帮助,请给点建议💗,切记学无止境。
  2. 针对文章所述内容,阅读期间任何疑问;请在文章底部批评指正,我会火速解决和修正问题。
  3. GitHub地址:https://github.com/CoderMikeHe
  4. 源码地址:
    MHDevelopExample目录中的Architecture文件夹中 <特别强调: 使用前请全局搜索 CMHDEBUG 字段并将该置为 1即可,默认是0 >
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,602评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,442评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,878评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,306评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,330评论 5 373
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,071评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,382评论 3 400
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,006评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,512评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,965评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,094评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,732评论 4 323
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,283评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,286评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,512评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,536评论 2 354
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,828评论 2 345

推荐阅读更多精彩内容

  • 1、通过CocoaPods安装项目名称项目信息 AFNetworking网络请求组件 FMDB本地数据库组件 SD...
    阳明先生_x阅读 15,968评论 3 119
  • 1、“不是我不想学,而是我无法集中注意力”——根源:专注力不足,干扰太多,电脑游戏, 2、“不是我不想学,而是我没...
    春天的蔓藤梅阅读 349评论 0 0
  • 这几天,地铁哺乳事件又一次引发了全民讨论,母亲在公共场所给孩子哺乳,究竟是该被谴责的不文明行为,还是该被理解的母性...
    时尚show阅读 290评论 1 3
  • 不知道你想做什么,但是我知道我想做什么。 带上背包我们一起去旅行。 不要去繁华的地方,怕你心乱,到了那里就不肯再陪...
    曲上未合阅读 264评论 0 0
  • 执行安装命令: 然后回车。结果为 查询软件 如搜索WeChat 结果为: 假如不知道软件的名字,先查询下包的名字,...
    哈利波特会魔法阅读 581评论 0 0