Как мы пришли к новому подходу работы с модулями в iOS приложении RaiffeisenBank.
Проблема
В приложениях Райффайзенбанка каждый экран состоит из нескольких, максимально независимых друг от друга модулей. «Модулем» мы называем визуальный компонент, имеющий своё представление. При проектировании приложения очень важно писать логику так, чтобы модули были независимыми и их легко можно было добавлять или убирать, не прибегая к рефакторингу.
Какие сложности перед нами стояли:
Выделение абстракции над архитектурным паттернами
Уже на первом этапе разработки стало понятно, что мы не хотим завязываться на определенный архитектурный паттерн. MVC хорош, если нужно вывести страничку с какой-то информацией. При этом взаимодействие с пользователем минимально или его вовсе нет. К примеру: страничка “о компании” или “пользовательское соглашение”. VIPER хороший инструмент для сложных модулей, имеющих свою логику работы с сервисами, роутингом и кучей всего.
Проблема взаимодействия и инкапсуляции
У каждого архитектурного паттерна своя структура построения и свои протоколы, которые накладывают ограничения по работе с модулем. Чтобы абстрагировать модуль, нужно выделить основные интерфейсы взаимодействия input/output.
Выделение логики роутинга
Модуль как визуальная единица не должен и не может знать о том, где и как его показывают. Один и тот же модуль должен и может быть внедрен как самостоятельная единица на любом экране или как композиция. Ответственность за это нельзя возлагать на сам модуль.
Предыдущее решение: //Гиблое дело
Первое решение мы написали на Objective-C, и в основе его лежал NSProxy. Проблема инкапсуляции архитектурного паттерна решалась дефеницией, которая определяла путем заданых условий, то есть input/output модуля, что позволяло проксировать любые обращения к модулю на его input и получать через output сообщения, если таковые были реализованы.
Это был шаг вперед, но возникли новые сложности:
- Интерфейс proxy не гарантировал реализацию протокола input;
- Output приходилось описывать, даже если он был не нужен;
- В интерфейс input нужно было добавлять свойство output.
Кроме NSProxy мы ещё реализовали роутинг, подсмотрев идею у ViperMcFlurry: сделали категорию на ViewController, которая начала расти по мере появления разных вариантов отображения модуля на экране. Конечно же, категорию мы делили, но это всё равно было далеко от хорошего решения.
В общем… первый блин комом, стало понятно, что нужно решать задачу иначе.
Решение: //Final
Поняв что с NSProxy дальше никак, взяли в руки маркеры и пошли рисовать. В итоге выделили протокол RFModule:
@objc
protocol RFModule {
var view: ViewController { get }
var input: AnyObject? { get }
var output: AnyObject? { get set }
var transition: Transitioning { get set }
}
Мы нарочно отказались от ассоциированных типов на уровне протокола, и для этого была веская причина: на тот момент 90% кода было на Objective-C. Взаимодействие между модулями ObjC<>Swift стало бы не возможным.
Чтобы всё-таки пользоваться дженериками и обеспечить типизированность использования модулей, мы ввели класс Module, удовлетворяющий протоколу
RFModule:
final class Module<I: Any, O: Any>: RFModule {
public typealias Input = I
public typealias Output = O
public var setOutput: ((O?) -> Void)?
//...
public var input: I? {
get { return inputObjc as? I}
set { inputObjc = newValue as AnyObject }
}
public var output: O? {
get { return outputObjc as? O}
set { outputObjc = newValue as AnyObject }
}
@objc(input)
public weak var inputObjc: AnyObject?
@objc(moduleOutput)
public weak var outputObjc: AnyObject? {
didSet{ setOutput?(output) }
}
}
@objc
protocol RFModule {
var view: ViewController { get }
@objc(input)
var inputObjc: AnyObject? { get }
@objc(moduleOutput)
var outputObjc: AnyObject? { get set }
var transition: Transitioning { get set }
}
public extension RFModule {
public var input: AnyObject? { return inputObjc }
public var output: AnyObject? { get { return outputObjc } set { outputObjc = newValue} }
}
Таким образом мы получили типизированный модуль. И по сути в Swift используется class Module, а в Objective-C RFModule. К тому же, это оказался удобный инструмент при затирании типов в том месте, где нужно создавать массивы: к примеру, TabContainer.
Так как DI создания модуля находится в скоупе UserStory, а присваивание значения output в том месте, где он будет использоваться, нельзя описать простой сетер. «setOutput» — это, по сути дефениция которая на этапе присвоения output передаст его ответственному в зависимости от логики модуля.
class SomeViewController: UIViewController, ModuleInput {
weak var delegate: ModuleOutput
}
class Assembly {
func someModule() -> Module<ModuleInput, ModuleOutput> {
let view = SomeViewController()
let module = Module<ModuleInput, ModuleOutput>(view: view, input: view) { [weak view] output in
view?.delegate = output
}
return module
}
}
...
let assembly: Assembly
let module = assembly.someModule()
module.output = self
Transitioning — это протокол, реализации которого, как понятно из названия, отвечают за логику показа и скрытия модуля.
protocol Transitioning {
var destination: ViewController? { get } // should be weak
func perform(_ completion: (()->())?) // present
func reverse(_ completion: (()->())?) // dissmiss
}
Для отображения вызывается — perform, для скрытия —reverse. Несмотря на то, что в протоколе есть destination и поначалу кажется, что должен быть source . На самом деле source может и не быть, и его тип это не всегда ViewController. К примеру, если нам необходимо, чтобы модуль открылся в новом окне — это Window, а если нужен embed , нужен И parent:ViewController И container: UIView.
class PresentTransition: Transitioning {
weak var source: ViewController?
weak var destination: ViewController?
...
func perform(_ completion: (()->())?) {
source.present(viewController: self.destinaton)
}
}
Таким образом мы избавились от идеи писать расширения на ViewController и описали логику того, как мы показываем наши модули в различных объектах. Это дало нам гибкость при роутинге, т.е. теперь мы можем показать любой модуль как самостоятельно, так и в комплексе, а также варьировать между тем, как это все показывается на экране: в окне (Window), Present, в навигации (push to navigation), embed, в шторке (cover).
Это все?
Есть еще одна вещь, которая пока не дает покоя. За возможность легко выбрать способ отображения модуля и вынос из него этой логики мы заплатили потерей возможности задавать свойства внешнего вида. К примеру, если мы показываем его в Navigation, нам нужно указывать, какого цвета должен быть barTintColor; или, если мы показываем модуль в шторке, есть необходимость выставить цвет handler-а.
Пока что мы решили эту проблему не типизированным свойством appearance: Any, и Transitioning при открытии модуля приводит к тому типу, с которым он работает, и, если у него это получилось, забирает нужные свойства.
Denis631
Интересненько, правда не вышло ли это что-то наподобие RIBs от Uber?
github.com/uber/RIBs