iOS使用Texture和Rxswitf进行MVVM架构的沸点页面仿写

3,744 阅读4分钟

0 3.JPEG

老习惯了的先展示下结果吧:

原版仿写1仿写2
原版仿写1 仿写2

# 补充文章

薅了这么久沸点的羊毛,是时候结束了🥶

# 技术路线

# 三方库选择:

  • Texture
  • Moya
  • RxSwift
  • Kingfisher
  • Lantern : 照片浏览器
  • Toast-Swift : toast 提示
  • etc...

# 整体架构

使用 Texture 进行的界面元素搭建,RxSwift 实现的 VM 数据流驱动 View 更新,Moya + Rx 的网络请求。

# Texture

对于 Texture 的使用其实上手难度不如我预想的大,官方的大量例子,和一些优秀的技术博客都能让我们很好地接入 Texture

布局:可以通过在网页(ASDK🐸)上以玩乐的形式上手 FlexBox 布局。

原理与使用

这里看一个布局点赞用户头像列表的布局吧:


// Title
private lazy var titleNode: ASTextNode = {
    let node = ASTextNode()
    let attr: [NSAttributedString.Key: Any] = [
        .font: UIFont.systemFont(ofSize: 12, weight: .regular),
        .foregroundColor: UIColor.XiTu.digCount
    ]
    node.attributedText = NSAttributedString(string: "等人赞过", attributes: attr)
    return node
}()

// 布局
override func layoutSpecThatFits(_ constrainedSize: ASSizeRange) -> ASLayoutSpec {

        let imageHeihgt = constrainedSize.min.height - 1
        imageNode1.style.preferredSize = CGSize(width: imageHeihgt, height: imageHeihgt)
        imageNode2.style.preferredSize = CGSize(width: imageHeihgt, height: imageHeihgt)
        imageNode3.style.preferredSize = CGSize(width: imageHeihgt, height: imageHeihgt)
        imageNode1.cornerRadius = imageHeihgt / 2
        imageNode2.cornerRadius = imageHeihgt / 2
        imageNode3.cornerRadius = imageHeihgt / 2

        let overlayWidth: CGFloat = 5
        let inset1 = ASInsetLayoutSpec(insets: .only(.right, value: 2 * (imageHeihgt - overlayWidth)), child: imageNode1)
        let inset2 = ASInsetLayoutSpec(insets: .only(.horizontal, value: imageHeihgt - overlayWidth), child: imageNode2)
        let inset3 = ASInsetLayoutSpec(insets: .only(.left, value:2 * (imageHeihgt - overlayWidth) ), child: imageNode3)
        let overlay1 = ASOverlayLayoutSpec(child: inset1, overlay: inset2)
        let overlay = ASOverlayLayoutSpec(child: overlay1, overlay: inset3)

        let hStack = ASStackLayoutSpec.horizontal()
        hStack.spacing = 5
        hStack.alignItems = .center
        hStack.children = [overlay, titleNode]

        return ASInsetLayoutSpec(insets: .only(.left, value: 7), child: hStack)
    }

# MVVMRxSwift

Demo 中并没有使用 双向数据绑定,未对 RxSwift 进行运算符重载,也未使用上次的 RxTableViewDataSource。整体代码可读性应该会有所提高吧,适合 RxSwift 入门学习。

ViewModelVM 层使用协议区分了 inputoutput,接口隔离:

protocol DynamicListViewModelInputs {

    func viewDidLoad()

    func refreshDate()

    func moreData(with cursor: String)

    // FIXED: - 以下 view 层接口需要根据产品的逻辑而定. 例如: 是否要处理一部分埋点之类的额外操作
 
    /// 查看详情
    func showDetail()

    /// 查看点赞用户
    func diggUserClick()
}

protocol DynamicListViewModelOutputs {

    //var willRefreshData: Observable<Void> { get }

    var refreshData: Observable<XTListResultModel> { get }

    var moreData: Observable<XTListResultModel> { get }

    var endRefresh: Observable<Void> { get }

    var hasMoreData: Observable<Bool> { get }

    var showError: Observable<String> { get }
}

protocol DynamicListViewModelType {
    var input: DynamicListViewModelInputs { get }
    var output: DynamicListViewModelOutputs { get }
}

final class DynamicListViewModel: DynamicListViewModelType, DynamicListViewModelInputs, DynamicListViewModelOutputs {

    var input: DynamicListViewModelInputs { self }
    var output: DynamicListViewModelOutputs { self }
    // ect...
}

View(VC) 中的使用:

extension DynamicListViewController {
    func eventListen() {
        self.mjHeader.refreshingBlock = { [unowned self] in
            self.viewModel.input.refreshDate()
        }

        self.mjFooter.refreshingBlock = { [unowned self] in
            self.viewModel.input.moreData(with: self.dataSource.nextCursor)
        }
    }
}

// MARK: - 绑定 viewModel

extension DynamicListViewController {

    func bindViewModel() {

        viewModel.output.refreshData.subscribe(onNext: { [weak self] wrappedModel in
            self?.dataSource.newData(from: wrappedModel)
            self?.tableNode.reloadData()
        }).disposed(by: disposeBag)

        viewModel.output.moreData.subscribe(onNext: { [weak self] wrappedModel in
            if let insertIndexPath = self?.dataSource.moreData(from: wrappedModel), !insertIndexPath.isEmpty {
                self?.tableNode.insertRows(at: insertIndexPath, with: UITableView.RowAnimation.automatic)
            }
        }).disposed(by: disposeBag)

        // etc...
    }
}

DataSource:对 ASTableNode (UITableView) 拆分出了 DataSource 来存储和处理 Model、遵守 ASTableDataSource 协议,将 VM 层的职责进一步的细分,便于维护。

这里就说这么多吧,更多的是在 cellNodecellNodeModel 里面的逻辑处理,和此类似。

备注:最近被大佬教育过不敢自称是 MVVM 架构,但数据处理层仍使用了 XxxNodeMode/ XxxViewModel 的名称。

Model: 直接使用 structCodable 进行 JSON 解析。注意,如果使用 UITableView 实现类似的界面效果可能需要在 model 中补充额外的存储属性和 Decoder 逻辑代码。

# Moya

掘金里相关技术文章也不少,这里不做说明了。

# 相关知识点

# ASImageNode + Kingfrisher

对于 ASImageNode 使用第三方图片库其实并不困难,特别是对于面向协议封装的 Kingfirsh,这里简单的扩展一下吧:

import Kingfisher
import AsyncDisplayKit


extension ASImageNode: KingfisherCompatible {}

public extension KingfisherWrapper where Base: ASImageNode {

    func setImage(
        with source: Resource?,
        placeholder: UIImage? = nil,
        failureImage: UIImage? = nil,
        options: KingfisherOptionsInfo? = nil,
        progressBlock: DownloadProgressBlock? = nil)
    {
        guard let source = source else {
            self.base.image = placeholder ?? failureImage
            return
        }

        KingfisherManager.shared.retrieveImage(with: source, options: options, progressBlock: progressBlock, downloadTaskUpdated: nil) { result in
            switch result {
            case .success(let retrieveResult):
                self.base.image = retrieveResult.image
            case .failure(_):
                self.base.image = failureImage ?? placeholder
            }
        }
    }
}

备注:老久没看 SDWebImage 的源码了,最近才发现 SDWebImage 内部也开始使用 协议实现协议的默认类 重构了,是我太久没关注的原因吗😅?

# 其他

  • 使 ASControl 支持 RxSwift
  • 正则(regx)表达式在 Swift 中的使用
  • 命名空间的创建
  • etc...

具体见源码吧。

# 关于源码

源码中已经将接口进行了移除,如果您有接口抓取能力(太简单的操作了)请在

  1. SceneDelegate.swift 中如下代码将 rootVC 更换为 HomeTabBarController

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        guard let _ = (scene as? UIWindowScene) else { return }
    
        guard let windowScene = (scene as? UIWindowScene) else { return }
        window = UIWindow.init(frame: windowScene.coordinateSpace.bounds)
        window?.windowScene = windowScene
    
        //let rootVC = HomeTabBarController()
        //let rootVC = UINavigationController(rootViewController: SimpleRegxViewController())
        let rootVC = UINavigationController(rootViewController: TextureDemoViewController())
        window?.rootViewController = rootVC
        window?.makeKeyAndVisible()
    
        UIWindow.setupLayoutFitInfo()
    }
    
  2. XTNetworkService.swift 中将 jjBaseUrlvar path{xxx} 替换到对应的接口。

如果仅是学习 Texture 布局等,源码中也附带了 xxx.json 文件供您本地使用(直接运行就好了)。

# 絮叨

2020年掘金的iOS客户端界面还在使用 Texture ,其中的沸点页面令初见 Texture 的我大吃一惊,也是从此开始接触并使用 Texture,其和 CSS 相似的 FlexBox 布局思想令人着迷。如今掘金的iOS版本经历了大的重构,去除了很多第三方库,这其中就包括 Texture,常用App中使用 Texture 的厂家又少了一个😞。这里也希望掘金iOS团队能够分享一下 Texture 使用过程中的利弊吧。