iOS App的页面应该尽可能使用UITableView来做,好处实在太多,在为什么要基于UITableview构建UI这篇文章有详细阐述,在这里就不赘述了。
归纳总结一下,UITableView页面应该分为同构复用cell
和异构不复用cell
两种。这两种页面细节有诸多不同,需要针对地做一些设计。阿里云App针对这两种页面做了不同的设计,大大提高了开发效率。下面就这两种页面的细节做一些讲述。
同构复用cell的页面
同构复用cell的页面主要的困难点是缓存、下拉刷新、上拉获取更多、翻页逻辑、空白页和错误页的显示等逻辑非常容易出错。为了消除这些逻辑的复杂性,我们设计了ALYUIViewControllerRefreshDataProtocol
协议,定义如下所示。
@objc public protocol ALYUIViewControllerRefreshDataProtocol : UITableViewDataSource, UITableViewDelegate {
// 有多个页面,可上拉获取更多
@objc optional func aly_fetchDataForMultiPageTableView(_ pageNum : UInt,
actionType: ALYLoadDataActionType,
successCallback : @escaping GetPageDataSuccessCallback,
failedCallback : @escaping GetPageDataFailedCallback)
// 只有单个页面
@objc optional func aly_fetchDataForSinglePageTableView(_ actionType: ALYLoadDataActionType,
successCallback : @escaping GetPageDataSuccessCallback,
failedCallback : @escaping GetPageDataFailedCallback)
//空白页
@objc optional func aly_viewForNoDataTableViewController() -> UIView?
//错误页
@objc optional func aly_viewForErrorTableViewController() -> UIView?
/*
* data source是否为空
* 当接口存在缓存的时候,first load使用缓存的数据,页面正常显示了。
* 但是接口出了问题,接着错误页会覆盖当前页面。
* 为了避免这种问题,在有数据的情况下,不要需要显示错误页。
*/
@objc optional func aly_isDataSourceEmpty() -> Bool
}
页面上不同的数据加载需要做不同的处理,所以我们定义下面几种数据操作类型。
@objc public enum ALYLoadDataActionType : Int {
case firstLoad = 0 //第一次载入数据,使用缓存
case refresh //普通下拉刷新等,不使用缓存
case refreshWithIndicator //需要切换数据源的刷新,比如ECS换了region,域名换了group,需要转菊花。
case fetchMore //上拉获取更多,不使用缓存
}
在扩展里面实现所有的逻辑。主要实现了fetchData和fetchMore函数,里面有缓存开关的设置、翻页控制、空白页和错误的显示控制。
extension UIViewController {
public var aly_hasMultiplePageInTableView : Bool {
get {
return self.responds(to: #selector(ALYUIViewControllerRefreshDataProtocol.aly_fetchDataForMultiPageTableView(_:actionType:successCallback:failedCallback:)))
}
}
//MARK: 刷新组件
fileprivate func enablePullToRefresh() {
self.aly_tableView.addPull {[weak self] () -> Void in
self?.aly_refreshData()
}
self.aly_tableView.showPullToRefresh = true
}
fileprivate func enablePullToGetMore() {
self.aly_tableView.addInfiniteScrolling {[weak self] () -> Void in
//上拉获取更多要调用 aly_fetchMore
self?.aly_fetchMore()
}
self.aly_tableView.showsInfiniteScrolling = true
}
//MARK: 网络请求
fileprivate func aly_fetchData(_ actionType: ALYLoadDataActionType) {
if actionType == .firstLoad || actionType == .refreshWithIndicator {
self.aly_firstLoadingView?.startAnimating()
}
if actionType != .firstLoad {
self.aly_hideErrorView()
self.aly_hideNoDataView()
}
if let _self = self as? ALYUIViewControllerRefreshDataProtocol {
let successCallback = {
[weak self] (dataNum : UInt) -> Void in
self?.aly_firstLoadingView?.stopAnimating()
self?.aly_tableView.stopRefreshAnimation()
self?.aly_hideErrorView()
//默认会开启下拉刷新
self?.enablePullToRefresh();
if dataNum == 0 {
self?.aly_tableView.reloadData()
self?.aly_showNoDataView()
return
} else {
self?.aly_hideNoDataView()
}
//默认有多页
if self?.aly_hasMultiplePageInTableView == true {
if let pagesize = self?.aly_pageSize, dataNum >= pagesize {
self?.aly_currentPageNum = 2
//拉取到足够多的页数才开始向上拉取更多功能
self?.enablePullToGetMore()
self?.aly_tableView.showsInfiniteScrolling = true
} else {
self?.aly_tableView.showsInfiniteScrolling = false
}
} else {
self?.aly_tableView.showsInfiniteScrolling = false
}
self?.aly_tableView.reloadData()
}
let failedCallback = {
[weak self] (exception : NSException) -> Void in
self?.aly_firstLoadingView?.stopAnimating()
self?.aly_tableView.stopRefreshAnimation()
self?.showFailureToast(exception.reason)
if actionType == .firstLoad
|| actionType == .refresh
|| actionType == .refreshWithIndicator {
if let aly_isDataSourceEmpty = _self.aly_isDataSourceEmpty {
//域名管理页有点特殊,tableView至少有一个cell,需要实现aly_isDataSourceEmpty接口
if aly_isDataSourceEmpty() == true {
self?.aly_showErrorView()
}
} else if let count = self?.aly_tableView.visibleCells.count, count > 0 { //一般情况下走这个分支
} else {
self?.aly_showErrorView()
}
}
self?.enablePullToRefresh();
}
if self.aly_hasMultiplePageInTableView == true {
self.aly_currentPageNum = 1
_self.aly_fetchDataForMultiPageTableView?(self.aly_currentPageNum, actionType: actionType, successCallback: successCallback, failedCallback: failedCallback)
}else {
_self.aly_fetchDataForSinglePageTableView?(actionType, successCallback: successCallback, failedCallback: failedCallback)
}
}
}
fileprivate func aly_fetchMore() {
let successCallback = {
[weak self] (dataNum : UInt) -> Void in
self?.aly_tableView.infiniteScrollingView.stopAnimating()
if dataNum == self?.aly_pageSize {
self?.aly_currentPageNum += 1
self?.aly_tableView.showsInfiniteScrolling = true
} else {
self?.aly_tableView.showsInfiniteScrolling = false
}
self?.aly_tableView.reloadData()
}
let failedCallback = {
[weak self] (exception : NSException) -> Void in
self?.aly_tableView.infiniteScrollingView.stopAnimating()
self?.aly_tableView.reloadData()
self?.showFailureToast(exception.reason)
}
if let _self = self as? ALYUIViewControllerRefreshDataProtocol {
_self.aly_fetchDataForMultiPageTableView?(self.aly_currentPageNum, actionType: ALYLoadDataActionType.fetchMore, successCallback: successCallback, failedCallback: failedCallback)
}
}
public func aly_firstLoad() {
self.aly_enableFirstLoading = true
self.aly_fetchData(ALYLoadDataActionType.firstLoad)
}
public func aly_refreshData(_ actionType: ALYLoadDataActionType = .refresh) {
self.aly_fetchData(actionType)
}
//MARK: 空白页显示逻辑
open func aly_showNoDataView() {
if self.aly_noDataView == nil {
var noDataView : UIView! = nil
//如果实现协议,就从协议里面获取空白页。否则给一个默认的空白页。
if let _self = self as? ALYUIViewControllerRefreshDataProtocol {
noDataView = _self.aly_viewForNoDataTableViewController?()
}
if noDataView == nil {
noDataView = ALYNoDataView()
}
self.aly_tableView.addSubview(noDataView)
self.aly_noDataView = noDataView
}
if self.aly_noDataView != nil {
self.aly_noDataView!.snp.makeConstraints({ (make) -> Void in
make.top.equalTo(self.aly_tableView)
make.leading.equalTo(self.aly_tableView)
make.width.equalTo(self.view)
make.height.equalTo(self.view)
})
}
}
open func aly_hideNoDataView() {
self.aly_noDataView?.removeFromSuperview()
self.aly_noDataView = nil
}
//MARK: 错误页显示逻辑
open func aly_showErrorView() {
if self.aly_errorView == nil {
var errorView : UIView! = nil
if let _self = self as? ALYUIViewControllerRefreshDataProtocol {
errorView = _self.aly_viewForErrorTableViewController?()
}
if errorView == nil {
errorView = ALYErrorView()
}
self.aly_tableView.addSubview(errorView)
self.aly_errorView = errorView
}
if self.aly_errorView != nil {
self.aly_errorView!.snp.makeConstraints({ (make) -> Void in
make.top.equalTo(self.aly_tableView)
make.leading.equalTo(self.aly_tableView)
make.width.equalTo(self.view)
make.height.equalTo(self.view)
})
}
}
open func aly_hideErrorView() {
self.aly_errorView?.removeFromSuperview()
self.aly_errorView = nil
}
有了这套机制之后,实现一个全功能的页面简直太方便了,最大的工作量可能是写cell吧。
不足之处
开发者仍然需要管理dataSource,网络请求返回数据后,做一些dataSource的增加和替换工作。虽然代码逻辑是一样的,但是还是比较繁琐,新手容易犯错误。
异构不复用cell的页面
异构不复用cell的页面一般用于设置这样的简单页面,在App里面占比也是很高的。开发这种页面的主要困难点是cell千奇百怪,点击的动作也各不相同。为了解决这个问题,我们设计了CellObject
,把数据、状态和cell上的视觉元素放到一起,用起来代码如下所示。避免在cellForRow里面写大量的if/else代码。
lazy var myActivityItem : ALYStandardCellObject = {
let item = ALYStandardCellObject.Builder()
.icon(name: "my_activity")
.text("我的云栖大会", color: UIColor.aly_black())
.selectionAction(select: { (sender) in
//do something
}).build()
return item
}()
比如ALYStandardCellObject
的实现就是把icon、imageView、text、textLabel放到一起,所有相关的因素放到一起。
open class ALYStandardCellObject : ALYCellObject {
//对应UITableViewCell的imageView属性
public var icon : UIImage? {
didSet {
self.imageView?.image = icon
}
}
public lazy var imageView : UIImageView? = {
let imageView = UIImageView()
return imageView
}()
//对应UITableViewCell的textLabel属性
public var text : String? {
didSet {
self.textLabel.text = text
}
}
public var textColor : UIColor? {
didSet {
self.textLabel.textColor = textColor
}
}
public lazy var textLabel : UILabel = {
let textLabel = UILabel()
textLabel.font = UIFont.aly_f7()
textLabel.textColor = UIColor.aly_black()
return textLabel
}()
}
因为CellObject非常紧凑,UITableViewDataSource和UITableViewDelegate相关的逻辑就非常简单了,代码都是一样的,只要抄一下就好了。
func numberOfSections(in tableView: UITableView) -> Int {
return self.dataSource.count
}
func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
return self.dataSource[section].count
}
func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
let cell = ALYCommonCell()
cell.bindObject(self.dataSource[indexPath.section][indexPath.row])
return cell
}
func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
tableView.deselectRow(at: indexPath, animated: true)
if let cell = tableView.cellForRow(at: indexPath) as? ALYCommonCell {
if let block = cell.object?.didSelectblock {
block(cell)
}
}
}
func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat {
return section == 0 ? 0.001 : 10
}
func tableView(_ tableView: UITableView, heightForFooterInSection section: Int) -> CGFloat {
let rows = self.dataSource[section].count
let object = self.dataSource[section][rows-1]
return object.promptViewHeight
}
func tableView(_ tableView: UITableView, viewForFooterInSection section: Int) -> UIView? {
let rows = self.dataSource[section].count
let object = self.dataSource[section][rows-1]
return object.promptView
}
因为页面一般比较简单,所以不用考虑cell的复用,数据和状态都维护在CellObject里面,所以很多时候数据有更新,并不需要reload data,直接设置CellObject里面的视觉元素就好了,非常之方便。
fileprivate func updateUnpayOrderCount() {
if ALYSessionManager.isLogin() {
let request = ALYRpcRequestObject()
.setApiName("xxx")
.setUseCache(true)
.setApiVersion("1.0")
ALYRPC.handyJsonModel(ALYCommonIntResultModel.self).asyncCall(request) { [weak self] (success, result, error) in
if success {
if let s = result?.intValue {
self?.orderItem.badgeNumber = s
}
}
}
}
}
Builder
异构cell因为元素比较多,所以构造函数比较复杂,为了解决这个问题,我们使用Builder模式,把相同的设置放到一起,用起来也是非常方便的。
lazy var myActivityItem : ALYStandardCellObject = {
let item = ALYStandardCellObject.Builder()
.icon(name: "my_activity")
.text("我的云栖大会", color: UIColor.aly_black())
.selectionAction(select: { (sender) in
//do something
}).build()
return item
}()
子类化
ALYStandardCellObject
对应标准的UITableViewCell,如果稍微有些不一样的,还是需要子类化的。比如加上小红点、消息数量等。
class ALYOrderCellObject : ALYStandardCellObject {
class Builder: ALYStandardCellObject.Builder {
override func build() -> ALYOrderCellObject {
let instance = ALYOrderCellObject()
self.assignClosures.forEach { (assign) in
assign(instance)
}
return instance
}
func badge(number: Int) -> Self {
self.assignClosures.append({ any in
(any as! ALYOrderCellObject).badgeNumber = number
})
return self
}
}
var badgeNumber : Int = 0 {
didSet {
self.layoutBadgeLabel()
}
}
var badgeWidthConstraint : Constraint?
lazy var badgeLabel : UILabel = {
let label = UILabel()
label.layer.backgroundColor = UIColor.aly_fromHex(0xf15533).cgColor
label.textAlignment = .center
label.textColor = UIColor.aly_ct_7()
label.font = UIFont.aly_f10()
label.layer.masksToBounds = true
label.layer.cornerRadius = 9
return label
}()
override func bindCell(_ cell: UITableViewCell) {
super.bindCell(cell)
cell.contentView.addSubview(self.badgeLabel)
self.badgeLabel.snp.remakeConstraints({ (make) in
make.centerY.equalToSuperview()
make.trailing.equalTo(0)
self.badgeWidthConstraint = make.width.equalTo(0).constraint
make.height.equalTo(18)
})
self.layoutBadgeLabel()
}
func layoutBadgeLabel() {
//do layout
}
}
扩展
有些cell可能要改accessory view,这种情况就更加简单了,只要扩展一下ALYStandardCellObject就行。
extension ALYStandardCellObject {
// 适配PKYStepper控件
func adaptStepper(_ decorateBlock: (ALYPKYStepper) -> Void, callbackBlock: @escaping ALYPKYStepperValueChangedCallback) {
let stepper = ALYPKYStepper()
decorateBlock(stepper)
stepper.valueChangedCallback = callbackBlock
self.accessoryView = stepper
var frame = stepper.frame;
frame.size.width = 111
frame.size.height = 30 //UITableViewCell.aly_getCellHeight(.OneLine)
stepper.frame = frame
}
}
Xcode snippet
为了避免大家写大量的重复代码,我们写了两个Xcode snippet,这样只要拖拽一下,然后填坑就好了,大大提高了效率。