精选文章

18. RxSwift + Moya 登录模板

2020-04-14 · RxSwift

18. RxSwift + Moya 登录模板

模板目标:输入校验、点击事件、请求、状态输出全部走响应式链路。

一、安装(SPM)

https://github.com/ReactiveX/RxSwift
https://github.com/Moya/Moya

在 Xcode 添加 Moya 时勾选 RxMoya 产品。

二、API 定义

import Moya

enum AuthAPI {
    case login(username: String, password: String)
}

extension AuthAPI: TargetType {
    var baseURL: URL { URL(string: "https://api.example.com")! }
    var path: String { "/login" }
    var method: Moya.Method { .post }
    var task: Task {
        switch self {
        case let .login(u, p):
            return .requestParameters(parameters: ["username": u, "password": p], encoding: JSONEncoding.default)
        }
    }
    var headers: [String : String]? { ["Content-Type": "application/json"] }
    var sampleData: Data { Data() }
}

三、Service(RxMoya)

import RxSwift
import Moya

struct Token: Decodable {
    let value: String
}

final class AuthService {
    private let provider = MoyaProvider<AuthAPI>()

    func login(username: String, password: String) -> Single<Token> {
        provider.rx.request(.login(username: username, password: password))
            .filterSuccessfulStatusCodes()
            .map(Token.self)
    }
}

四、ViewModel(MVVM)

import RxSwift
import RxCocoa

final class LoginViewModel {
    struct Input {
        let username: Observable<String>
        let password: Observable<String>
        let loginTap: Observable<Void>
    }

    struct Output {
        let canLogin: Observable<Bool>
        let loading: Observable<Bool>
        let success: Observable<Token>
        let error: Observable<Error>
    }

    private let service: AuthService
    private let activity = ActivityIndicator()
    private let errorTracker = PublishSubject<Error>()

    init(service: AuthService) {
        self.service = service
    }

    func transform(_ input: Input) -> Output {
        let canLogin = Observable.combineLatest(input.username, input.password)
            .map { !$0.isEmpty && !$1.isEmpty }
            .distinctUntilChanged()

        let request = input.loginTap
            .withLatestFrom(Observable.combineLatest(input.username, input.password))
            .flatMapLatest { [service, errorTracker, activity] u, p in
                service.login(username: u, password: p)
                    .trackActivity(activity)
                    .do(onError: { errorTracker.onNext($0) })
                    .asObservable()
                    .catch { _ in .empty() }
            }
            .share()

        return Output(
            canLogin: canLogin,
            loading: activity.asObservable(),
            success: request,
            error: errorTracker.asObservable()
        )
    }
}

五、ActivityIndicator(可直接用)

import RxSwift
import RxCocoa

final class ActivityIndicator: SharedSequenceConvertibleType {
    typealias SharingStrategy = DriverSharingStrategy
    private let relay = BehaviorRelay(value: 0)

    func trackActivity<O: ObservableConvertibleType>(_ source: O) -> Observable<O.Element> {
        Observable.using({ () -> ActivityToken<O.Element> in
            self.increment()
            return ActivityToken(source: source.asObservable(), disposeAction: self.decrement)
        }) { $0.asObservable() }
    }

    func asSharedSequence() -> SharedSequence<DriverSharingStrategy, Bool> {
        relay.asDriver().map { $0 > 0 }.distinctUntilChanged()
    }

    func asObservable() -> Observable<Bool> {
        relay.asObservable().map { $0 > 0 }.distinctUntilChanged()
    }

    private func increment() { relay.accept(relay.value + 1) }
    private func decrement() { relay.accept(relay.value - 1) }
}

private struct ActivityToken<E>: ObservableConvertibleType, Disposable {
    private let source: Observable<E>
    private let disposeAction: () -> Void

    init(source: Observable<E>, disposeAction: @escaping () -> Void) {
        self.source = source
        self.disposeAction = disposeAction
    }

    func dispose() { disposeAction() }
    func asObservable() -> Observable<E> { source }
}

六、ViewController 绑定

import UIKit
import RxSwift
import RxCocoa

final class LoginVC: UIViewController {
    private let username = UITextField()
    private let password = UITextField()
    private let loginButton = UIButton(type: .system)
    private let loadingView = UIActivityIndicatorView(style: .medium)

    private let disposeBag = DisposeBag()
    private let viewModel = LoginViewModel(service: AuthService())

    override func viewDidLoad() {
        super.viewDidLoad()
        view.backgroundColor = .white

        username.placeholder = "Username"
        password.placeholder = "Password"
        password.isSecureTextEntry = true
        loginButton.setTitle("Login", for: .normal)

        let stack = UIStackView(arrangedSubviews: [username, password, loginButton, loadingView])
        stack.axis = .vertical
        stack.spacing = 12
        stack.translatesAutoresizingMaskIntoConstraints = false
        view.addSubview(stack)

        NSLayoutConstraint.activate([
            stack.centerXAnchor.constraint(equalTo: view.centerXAnchor),
            stack.centerYAnchor.constraint(equalTo: view.centerYAnchor),
            stack.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 24),
            stack.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -24)
        ])

        let input = LoginViewModel.Input(
            username: username.rx.text.orEmpty.asObservable(),
            password: password.rx.text.orEmpty.asObservable(),
            loginTap: loginButton.rx.tap.asObservable()
        )

        let output = viewModel.transform(input)

        output.canLogin
            .bind(to: loginButton.rx.isEnabled)
            .disposed(by: disposeBag)

        output.loading
            .bind(to: loadingView.rx.isAnimating)
            .disposed(by: disposeBag)

        output.success
            .subscribe(onNext: { token in
                print("token: \(token.value)")
            })
            .disposed(by: disposeBag)

        output.error
            .subscribe(onNext: { error in
                print(error)
            })
            .disposed(by: disposeBag)
    }
}

这个模板是完整可运行的登录链路,直接替换 API 地址与 Token 模型即可落地。

JJ

作者简介

专注于内容创作、产品策略与设计实践。欢迎交流合作。

上一篇 下一篇