¡Hola, Habr!
Mi nombre es Igor, soy Jefe de Móvil en AGIMA.
Muchos proyectos y evaluaciones pasan por nosotros , la funcionalidad muchas veces se repite ahí, así que decidí mostrarte cómo resolvemos tareas típicas y compartirlo contigo. Empezaremos desde el principio. Normalmente, las aplicaciones comienzan con una autorización. Consideremos el caso clásico de ingresar un número de teléfono y un SMS y detenernos con más detalle en la pantalla de confirmación de SMS.
Importante: el código de ejemplo en github tendrá un ejemplo completo al ingresar un número de teléfono y un código, pero la pantalla de ingreso del número de teléfono es bastante aburrida, así que hoy ingresamos el código :)
No parece muy difícil, pero si miras de cerca, la funcionalidad de la pantalla es bastante grande, a saber:
enviar el código al servidor;
habilitar el temporizador de reenvío + mostrar visualmente;
una vez finalizado el temporizador, muestra el botón "enviar de nuevo";
;
;
.
UI , .
, , isLoading View , . , MVVM+Rx ( ), . .
ViewModel «» : input output ( ). «- », , .
UI :
final class ConfirmCodeViewController: BaseViewController {
///
private lazy var codeTextField = CodeTextField()
///
private lazy var errorLabel = UILabel()
///
private lazy var loader = UIActivityIndicatorView()
///
private lazy var timerLabel = UILabel()
///
private lazy var retryButton = UIButton(type: .system)
///
private lazy var stackView = UIStackView()
}
ViewModel :
/// , .
enum AuthResult {
case success
case needPersonalData
}
protocol ConfirmCodeViewModelProtocol {
///
var code: AnyObserver<String> { get }
/// « »
var getNewCode: AnyObserver<Void> { get }
///
var didAuthorize: Driver<AuthResult> { get }
///
var isLoading: Driver<Bool> { get }
///
var errors: Driver<String> { get }
///
var newCodeTimer: Driver<Int> { get }
/// « »
var didRequestNewCode: Driver<Void> { get }
///
var codeTimerIsActive: Driver<Bool> { get }
}
, PublishSubject, BehaviourRelay , input output ViewModel. .
View :
let codeText = codeTextField.rx.text.share()
codeText
.bind(to: viewModel.code)
.disposed(by: disposeBag)
retryButton.rx.tap
.bind(to: viewModel.getNewCode)
.disposed(by: disposeBag)
ViewModel - ( ) , , .
ViewModel « »:
let _codeSubject = PublishSubject<String>()
self.code = _codeSubject.asObserver()
let codeObservable = _codeSubject.asObservable()
let validCodeObservable = codeObservable.filter { $0.count == codeLength }
_codeSubject
— textfield .
validCodeObservable
— , .
, PublishSubject
, AnyObserver
, Observable
, , , . : AnyObserver
Observable PublishSubject
.
let codeEvents: Observable<Result<Void, Error>> = validCodeObservable
.flatMap { (code) in
authService.confirmCode(code: code, token: token).materialize()
}.share()
, :) .materialize()
. Observable
, . materialize Result<Value, Error>
- .
. , , . , , . , ( ), true
false
isLoading
.
didAuthorize = codeEvents.elements()...
.elements(
) codeEvents . , codeEvents
— Result<Void, Error>
, RxSwiftExt.
:
(validCodeObservable.mapTo(Void()))
;
(didRequestNewCode)
;
(.startWith(Void()))
.
Observable.merge...
RxSwift. take(while:)
, 0.
«» / , :
viewModel.codeTimerIsActive .drive(retryButton.rx.isHidden) .disposed(by: disposeBag) viewModel.codeTimerIsActive .not() .drive(timerLabel.rx.isHidden) .disposed(by: disposeBag)
errors.
errors = codeEvents.errors().merge(with: fetchNewCode.errors())
.compactMap { ($0 as? ErrorType)?.localizedDescription }
.asDriver(onErrorJustReturn: "")
, , :
viewModel.isLoading .not() .drive(codeTextField.rx.isEnabled) .disposed(by: disposeBag)
ViewModel - , ! , , ViewModel . , . , RxTest!
class ConfirmCodeViewModelTests: XCTestCase {
// properties
// methods
//MARK:- Helpers
private func bindCodeInputEvents(
_ events: [Recorded<Event<String>>] = [.next(100, "1"), .next(200, "11"), .next(300, "111"), .next(400, "1111")])
{
codeInputEvents = scheduler.createHotObservable(events)
codeInputEvents.bind(to: viewModel.code).disposed(by: disposeBag)
}
}
, — :
func test_timerInvokedAutomatically() {
let sut = scheduler.start(created: 0, subscribed: 0, disposed: 1000) { self.viewModel.newCodeTimer }
XCTAssertEqual(sut.events, [.next(1, 2), .next(2, 1), .next(3, 0)])
}
: , UI
func test_errorEmmitedValueAtFailure() throws {
bindCodeInputEvents()
setConfirmCodeResult(.error(0, MockError.confirmFailure))
let sut = scheduler.start { self.viewModel.errors }
XCTAssertEqual(sut.events, [.next(400, "confirmFailure")])
}