Swift 开发 wanandroid 客户端——基类控制器BaseViewController、BaseTableViewController

3,025 阅读7分钟

这是我参与更文挑战的第24天,活动详情查看: 更文挑战

每日更新,我到底在做啥?

很多朋友总是被我的标题给迷惑了,Swift?玩安卓App?都是些啥?

我反思了一下,确实自己的标题取得不太好。加上有没有附图我到底在做啥,是我的失误。

所以我决定还是传一张Gif给大家看看,我都在写一个什么样的东东,一个没啥太多华丽UI,用Swift编写的iOS App,基本上我编写的代码和更文算是同步的:

RPReplay_Final1624432101.2021-06-24 08_35_54.gif

为什么要写基类控制器

给大家分享一个自己刚入行的经历,我刚刚从事iOS开发。

那会还在写OC代码,新手总是从写UI开始的,我也不例外,由于事先大佬也没有叮嘱写什么,我就开始讲自己写的Controller一个个创建,大概就是这样

@interface XXXXController : UIViewController

没什么啥毛病。

有天,不知道是产品还是UI来了什么灵感,说我们这页面的背景色需要做些许改动,大佬说,好的没事,一行代码的事。然后大佬确实改了一行代码提交看了效果,不错,然后就开开心心提测了。

于是,测试就过来了,你写的这页面好奇怪啊,大部分的页面背景色都是一致的,但是有几个怎么都看起来有色差,怎么回事?

不用多说,有色差的页面都是我写的,至于原因很简单,大佬写的代码都是这样的:

@interface XXXXController : BaseViewController

其他页面写的时候都是继承的基类控制器,只有我继承UIViewController,大佬也没有责怪我,因为他以为我知道这种规则就没和我交代,所以出了差错,加上改改继承基本上就解决问题,所以也不是什么大事。

分享我的这个经历,其实在之后的工作中给了我很多思考:

  • 一个App中,大部分页面的UI风格、颜色、样式基本上都是一致,通过继承自定义的BaseViewController可以很快的完成基础配置。

  • 其实不仅是Controller层,有的时候包括View层,我们需要定义一个BaseView,来进行基础配置,如果有业务需要BaseTableViewCell等都是可以考虑的。这个就和写BaseModel有些相似。

  • 自己写项目,记得要做一些基类的编写,自己接手其他人的项目,我也总会先让别人给我介绍一下他们的基类。

编写BaseViewController

基于上面的工作经历与思考,现在我们就来玩安卓App的BaseViewController吧:

import UIKit

import RxSwift
import RxCocoa

class BaseViewController: UIViewController {
    
    private lazy var errorImage: UIImageView = {
        let imageView = UIImageView(image: R.image.saber())
        imageView.contentMode = .scaleAspectFit
        return imageView
    }()
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        /// 最简单的设置统一返回按钮的方法,所有的控制器继承该基类即可
        let leftBarButtonItem = UIBarButtonItem(image: R.image.back(), style: .plain, target: self, action: #selector(leftBarButtonItemAction(_:)))
        navigationItem.leftBarButtonItem = (navigationController?.viewControllers.count ?? 0) > 1 ? leftBarButtonItem : nil
        navigationItem.hidesBackButton = true
        
        /// 这里的代码有问题,需要注释掉
        //navigationController?.interactivePopGestureRecognizer?.delegate = nil
                
        view.backgroundColor = .white
        
        setupErrorImage()
    }
        
    @objc
    private func leftBarButtonItemAction(_ item: UIBarButtonItem) {
        navigationController?.popViewController(animated: true)
    }
    
    deinit {
        print("\(className)被销毁了")
    }

}

//MARK:- 网络请求错误页面的配置项(待用)
extension BaseViewController {
    private func setupErrorImage() {
        view.addSubview(errorImage)
        errorImage.snp.makeConstraints { make in
            make.edges.equalTo(view)
        }
        errorImage.isHidden = true
    }
    
    func showErrorImage() {
        errorImage.isHidden = false
        view.bringSubviewToFront(errorImage)
    }
    
    func hiddenErrorImage() {
        errorImage.isHidden = true
        view.sendSubviewToBack(errorImage)
    }
}

//MARK:- 绑定
extension Reactive where Base: BaseViewController {
    
    /// 显示网络错误
    var networkError: Binder<Void> {
        return Binder(base) { vc, _ in
            vc.showErrorImage()
        }
    }
}

其实这样BaseViewController做了一下几件事情:

  • 自定义返回按钮

    • 这里我们使用自定义的leftBarButtonItem去代替了系统的backButton,代码块中这种方式是目前我见过设置最简单、功能不会缺失的好办法。只要UINavigationControlle初始化方法传入的是BaseViewController的子类即可实现。

    • 系统的侧滑没有失效。

    • 点击leftBarButtonItem的返回事件。

    • 勘误:上面这段话是有问题的,有大佬nlnlnull留言说,我这样写,会在根控制器中尝试使用侧滑手势后,会出现异常情况,已经验证,确实如此。

    自己写的代码没有好好验证与追根朔源,还是非常感谢大佬的提醒,具体地址的问题请看这篇文章:自定义leftBarButtonItem导致侧滑失效

    BaseViewController中需要删除这段代码,在代码块中,删除无法显示,这里单独说明:

    navigationController?.interactivePopGestureRecognizer?.delegate = nil

    因此,我们还需要写一个BaseNavigationController,来避免这个问题的发生:

    import UIKit
    
    class BaseNavigationController: UINavigationController {
    
        override func viewDidLoad() {
            super.viewDidLoad()
            interactivePopGestureRecognizer?.delegate = self
            delegate = self
        }
    }
    
    extension BaseNavigationController: UIGestureRecognizerDelegate, UINavigationControllerDelegate {
        func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
            interactivePopGestureRecognizer?.isEnabled = true
            /// 解决某些情况下push时的假死bug,防止把根控制器pop掉
            if (navigationController.viewControllers.count == 1) {
                interactivePopGestureRecognizer?.isEnabled = false
            }
        }
    }
    
    
  • 配置了控制器的背景色为白色。

  • 通过RxSwift,针对网络请求导致的页面进行了页面处理,这一块由于Rx我也是一点点学习,目前可能思路与处理都不算太好,这里只是写出来了。

  • 在析构函数中添加了一段打印,用于查看控制器的销毁情况。

之前,我也说过,玩安卓App中有很多页面都是列表,考虑到这种情况,编写一个BaseTableViewController也是很有的必要的。

编写BaseTableViewController

首先BaseTableViewController它是继承于BaseViewController。

同时由于是为了展示列表,我们需要在里面布局一个UITableView。

考虑列表可能会有数据为空的情况,我们需要对页面做定制化处理,这里我选择使用了OC库——DZNEmptyDataSet

import UIKit

import RxSwift
import RxCocoa

import MJRefresh
import DZNEmptyDataSet

class BaseTableViewController: BaseViewController {
    
    lazy var tableView = UITableView(frame: .zero, style: .plain)
    
    let emptyDataSetButtonTap = PublishSubject<Void>()
    
    let isEmpty = BehaviorRelay(value: false)

    override func viewDidLoad() {
        super.viewDidLoad()
        setupTableView()
    }
    
    private func setupTableView() {
        
        /// 设置tableFooterView
        tableView.tableFooterView = UIView()
        
        /// 设置代理
        tableView.rx.setDelegate(self).disposed(by: rx.disposeBag)
        
        /// 简单布局
        view.addSubview(tableView)
        tableView.snp.makeConstraints { make in
            make.edges.equalTo(self.view)
        }
        
        /// 设置头部刷新控件
        tableView.mj_header = MJRefreshNormalHeader()
        /// 设置尾部刷新控件
        tableView.mj_footer = MJRefreshBackNormalFooter()
        
        /// 设置DZNEmptyDataSet的数据源和代理
        tableView.emptyDataSetSource = self
        tableView.emptyDataSetDelegate = self
        
        /// 订阅点击了数据为空,请重试的行为,里面没有用状态去绑定tableView是因为没有ViewModel
        emptyDataSetButtonTap.subscribe { [weak self] _ in
            self?.tableView.mj_header?.beginRefreshing()
        }.disposed(by: rx.disposeBag)
        
        /// 数据为空的订阅(待用)
        isEmpty.subscribe { event in
            switch event {
            case .next(let noContent):
                break
            default:
                break
            }
        }.disposed(by: rx.disposeBag)
    }

}

//MARK:- UITableViewDelegate
extension BaseTableViewController: UITableViewDelegate {}

//MARK:- DZNEmptyDataSetSource
extension BaseTableViewController: DZNEmptyDataSetSource {

    func title(forEmptyDataSet scrollView: UIScrollView!) -> NSAttributedString! {
        return NSAttributedString(string: "暂无数据")
    }

    func description(forEmptyDataSet scrollView: UIScrollView!) -> NSAttributedString! {
        return NSAttributedString(string: "尝试点击刷新获取数据")
    }

    func backgroundColor(forEmptyDataSet scrollView: UIScrollView!) -> UIColor! {
        return .clear
    }

    func verticalOffset(forEmptyDataSet scrollView: UIScrollView!) -> CGFloat {
        return -60
    }
}

//MARK:- DZNEmptyDataSetSource
extension BaseTableViewController: DZNEmptyDataSetDelegate {

    func emptyDataSetShouldDisplay(_ scrollView: UIScrollView!) -> Bool {
        return isEmpty.value
    }

    func emptyDataSetShouldAllowScroll(_ scrollView: UIScrollView!) -> Bool {
        return true
    }
    
    func emptyDataSet(_ scrollView: UIScrollView!, didTap view: UIView!) {
        emptyDataSetButtonTap.onNext(())
    }
}

其实这段DZNEmptyDataSet的代码,基本上和OC时代写的代码没什么太多差别,我甚至去看了知名开源App——SwiftHub,想看看大佬有没有对DZNEmptyDataSet做一层RxSwift的封装,写起来更简单。

结论是没有!SwiftHub也是在分类里面写实现数据源和代理的方式对页面为空的情况做处理。

于是我也在想,费尽精力的去写第三库的RxSwift扩展,不如直接用来的省事。

总结

由于之前写的积分排行页面——RxSwiftCoinRankListController是一个独立的讲解页面,没有过多去讲解BaseViewController。

虽然当时已经使用过了这个基类了,但是笔墨更多的是讲解网络请求和上拉与下拉的操作行为。

随着我开始写首页的HomeViewModel,我才意识到我漏掉了这一环。

编写基类,虽然不是必须的,但是有了基类,可能会让平时的编码中更为轻松一点,虽然Swift更偏向面向协议编程,但是面向对象编程已经存在这么多年了,它也有它的优势,继承使用的小心慎重,思考是否需要继承都是思考的结晶。

明日继续

讲完基类控制器,下面该讲解首页的编写了。

大家加油!