Если вы пишете приложение мобильного банка для iOS, какие у вас приоритеты? Думаю, их два:
- Надёжность;
- Скорость внесения изменений.
Ситуация такова, что нужно уметь вносить изменения (и в частности выкатывать новые банковские продукты) действительно быстро. Но при этом не скатываться в индусокод и копипаст (см. пункт 1). Всё это при том, что приложение действительно огромное по функционалу, по крайней мере в задумке (банки хотят намного больше, чем умеют). Соответственно, во многих случаях это проекты на десятки человеко-лет. Те, кто участвовал в таких проектах, уже наверно поняли, что задача нетривиальная, и школьные знания тут не помогут.
Что же делать?
Сразу понятно, что это задача по архитектуре. Прежде чем писать код, я увидел простую и красивую блочную архитектуру фронтенда нашей ДБО. Не видел раньше подобного в мобильных приложениях, но в итоге сделал аналогичное решение, вполне эффективное и масштабируемое, в рамках iOS SDK, без добавления массивных фреймворков. Хочу поделиться основными моментами.
Да, про все эти рамки SDK, про эппловские косяки особенности паттерна MVC и про быдлокод в официальной документации знают все, кто пытался писать и поддерживать серьёзные приложения (или хотя бы читал Хабр) — не буду повторяться. Здесь также не будет основ программирования, файлов с примерами и фотографий с котиками.
Первая гуглокартинка по запросу «advanced architecture»
Суть решения
Операции состоят из атомов.
Операция — это интерфейс для совершения конкретного действия в АБС. Например, перевод между своими счетами.
Атом — это стабильная «капсула» из MVC, некий кирпичик, из которого можно строить дома любого размера и формы. Каждый кирпичик имеет свою сущность в UI: надпись, поле для ввода, картинка. Каждый значимый UI-блок инкапсулируется в такой MVC-кирпичик. Например, UISegmentedControl
инкапсулируется в SegmentedAtom
.
Эти MVC-кирпичики пишутся один раз. Дальше каждая операция строится из этих кирпичиков. Положить кирпичик — это одна строчка. Одна строчка! Дальше получить значение — это опять одна строчка. Всё остальное решается наследованием и полиморфизмом в подклассах Atom. Задача — максимально упростить код самих операций, т.к. именно они могут сильно меняться. Кирпичики принципиально не меняются (или даже вообще не меняются).
Атом может быть и более сложным элементом. Он может инкапсулировать некую логику и дочерние ViewController
'ы. Например, атом для выбора счёта из списка (причём по фильтру из контекста). Главное, чтобы вся сложность осталась внутри атома. Снаружи он остаётся таким же простым в использовании.
Мою концепцию уже используют в строительстве
Для примера, операция отправки платёжного поручения «по быстрой форме» (из кода скрыл все моменты, связанные с безопасностью, и да, права на код принадлежат ООО «Цифровые Технологии Будущего»):
class PayPPQuickOperation : Operation, AtomValueSubscriber {
private let m_dataSource = PayPPDataSource()
private var m_srcTitle, m_destBicInfo: TextAtom!
private var m_destName, m_destInn, m_destKpp, m_destAccount, m_destBic, m_destDesc, m_amount: TextInputAtom!
private var m_button: ButtonAtom!
override init() {
super.init()
create()
initUI()
}
func create() {
m_srcAccount = AccountPickerAtom(title: "Списать со счета")
m_destName = TextInputAtom(caption: "ФИО или полное наименование получателя")
m_destInn = TextInputAtom(caption: "ИНН получателя", type: .DigitsOnly)
m_destKpp = TextInputAtom(caption: "КПП получателя", type: .DigitsOnly)
m_destAccount = TextInputAtom(caption: "Счет получателя", type: .DigitsOnly)
m_destBic = TextInputAtom(caption: "БИК", type: .DigitsOnly)
m_destBicInfo = TextAtom(caption: "Введите БИК, банк будет определен автоматически")
m_destDesc = TextInputAtom(caption: "Назначение платежа")
m_amount = TextInputAtom(caption: "Сумма, ₽", type: .Number)
m_button = ButtonAtom(caption: "Перевести")
// Здесь можно быстро переставлять местами операции:
self.m_atoms = [
m_srcAccount,
m_destName,
m_destInn,
m_destKpp,
m_destAccount,
m_destBic,
m_destBicInfo,
m_destDesc,
m_amount,
m_button,
]
m_destInn!.optional = true
m_destKpp!.optional = true
m_button.controlDelegate = self // это значит, что тап придёт в onButtonTap ниже
m_destBic.subscribeToChanges(self)
}
func initUI() {
m_destName.wideInput = true
m_destAccount.wideInput = true
m_destDesc.wideInput = true
m_destBicInfo.fontSize = COMMENT_FONT_SIZE
m_destName.capitalizeSentences = true
m_destDesc.capitalizeSentences = true
}
func onAtomValueChanged(sender: OperationAtom!, commit: Bool) {
if sender == m_destBic && commit == true {
m_dataSource.queryBicInfo(sender.stringValue,
success: { (bicInfo: BicInfoReply?) in
self.m_destBicInfo.caption = bicInfo?.data.name
},
failure: { (error: NSError?) in
// обработка ошибки, в данном случае не страшно
self.m_destBicInfo.caption = ""
})
}
}
func onButtonTap(sender: AnyObject?) {
// если несколько кнопок, то для каждого sender будет своё действие
var hasError = false
for atom in m_atoms {
if atom.needsAttention {
atom.errorView = true
hasError = true
}
}
if !hasError {
var params: [String : AnyObject] = [
"operation" : "pp",
"from" : m_srcAccount.account,
"name" : m_destName.stringValue,
"kpp" : m_destKpp.stringValue,
"inn" : m_destInn.stringValue,
...
"amount" : NSNumber(double: m_amount.doubleValue),
]
self.showSignVC(params)
}
}
}
И это весь код операции!!! Больше ничего не нужно, при условии, что у вас в наличии есть все нужные атомы. В данном случае они у меня уже были. Если нет, пишем новый и рисуем в сториборде. Конечно же, можно наследоваться от существующих.
onAtomValueChanged()
— это имплементация протокола AtomValueSubscriber
. Мы подписались на изменения текстового поля «БИК» и там делаем запрос, который возвращает имя банка по БИК. Значение commit == true
для текстового поля приходит из евента UIControlEventEditingDidEnd
.
Последняя строчка showSignVC()
— показать ViewController
для операции подписания, которая представляет из себя просто ещё одну операцию, которая состоит из тех же самых простых атомов (формируется из элементов матрицы безопасности, которые приходят с сервера).
Я не привожу код класса Operation
, т.к. вы можете найти более удачное решение. Я решил (в целях экономии времени разработки) скармливать m_atoms
табличному ViewController
'у. Все «кирпичики» нарисованы в IB ячейками таблиц, инстанцируются по cell id. Таблица даёт автоматический пересчёт высоты и прочие удобства. Но получается, что сейчас размещение атомов у меня возможно только в таблице. Для айфона хорошо, для айпада может быть не очень. Пока лишь единичные банки в российском аппсторе грамотно используют пространство на планшетах, остальные тупо копируют UI айфонов и только добавляют левую менюшку. Но в идеале да, надо переделать на UICollectionView
.
Зависимости
Как известно, при вводе каких-либо форм, в зависимости от значений одних полей, другие поля могут видоизменяться или прятаться, как мы видели на примере БИК выше. Но там одно поле, а в полной платёжке их в разы больше, поэтому нужен простой механизм, который потребует минимум телодвижений для динамического показа/скрытия полей. Как это легко сделать? На помощь снова приходят атомы :) а также NSPredicate
.
Вариант 1:
func createDependencies() {
// m_ppType - выбор типа платёжки, что-то вроде выпадающего списка (EnumAtom)
m_ppType.atomId = "type"
m_ppType.subscribeToChanges(self)
let taxAndCustoms = "($type == 1) || ($type == 2)"
...
// а это поля ввода:
m_106.dependency = taxAndCustoms
m_107.dependency = taxAndCustoms
m_108.dependency = taxAndCustoms
...
Это пример для платёжек. Аналогичная система работает для электронных платежей в пользу различных провайдеров, где в зависимости от некоторого выбора (например, платить по счётчикам или произвольную сумму) пользователь заполняет разный набор полей.
Вот тут ребята создали для этих целей «некий фреймворк», но мне как-то хватило нескольких строк.
Вариант 2, если вам не хватает языка предикатов, или вы его не знаете, то считаем зависимости функцией, например, isDestInnCorrect():
m_destInn.subscribeToChanges(self)
m_destInnError.dependency = "isDestInnCorrect == NO"
Думаю, может, будет более красиво, если переименовать свойство:
m_destInnError.showIf("isDestInnCorrect == NO")
Текстовый атом m_destInnError
, в случае некорректного ввода, рассказывает пользователю, как правильно заполнять ИНН по 107-му приказу.
К сожалению, компилятор за вас не проверит, что данная операция имплементирует метод isDestInnCorrect()
, хотя это наверно можно сделать макросами.
И собственно установка видимости в базовом классе Operation
(сорри за Objective C):
- (void)recalcDependencies {
for (OperationAtom *atom in m_atoms) {
if (atom.dependency) {
NSPredicate *predicate = [NSPredicate predicateWithFormat:atom.dependency];
BOOL shown = YES;
NSDictionary<NSString*, id> *vars = [self allFieldsValues];
@try {
shown = [predicate evaluateWithObject:self substitutionVariables:vars];
}
@catch (NSException *exception) {
// тут отладочная инфа
}
atom.shown = shown;
}
}
}
Посмотрим, какие ещё будут проверки. Возможно, операция не должна эти заниматься, т.е. всё перепишется на вариант
let verifications = Verifications.instance
...
if let shown = predicate.evaluateWithObject(verifications,
substitutionVariables: vars) { ...
One More Thing
А вообще, что касается объектных паттернов в клиент-серверном взаимодействии, не могу не отметить исключительное удобство при работе с JSON, которое даёт библиотека JSONModel. Dot notation вместо квадратных скобок для получаемых JSON-объектов, и сразу типизация полей, в т.ч. массивы и словари. Как результат, сильное повышение читаемости (и как следствие надёжности) кода для больших объектов.
Берём класс JSONModel
, наследуем от него ServerReply
(ибо каждый ответ содержит базовый набор полей), от ServerReply
наследуем ответы сервера на конкретные виды запросов. Основной минус библиотеки — её нет на Swift (т.к. она работает на неких языковых лайфхаках), и по той же причине синтаксис странноват…
@class OperationData;
@protocol OperationOption;
#pragma mark OperationsList
@interface OperationsList : ServerReply
@property (readonly) NSUInteger count;
@property OperationData<Optional> *data;
...
@end
#pragma mark OperationData
@interface OperationData : JSONModel
@property NSArray<OperationOption, Optional> *options;
@end
#pragma mark OperationOption
@interface OperationOption : JSONModel
@property NSString<Optional> *account;
@property NSString<Optional> *currency;
@property BOOL isowner;
@property BOOL disabled;
...
Но всё это легко юзается из Swift-кода. Единственное, чтобы приложение не падало из-за неправильного формата ответа сервера, все NSObject
-поля нужно помечать как <Optional
> и самому проверять на nil
. Если хотим своё свойство, чтобы оно не мешалось JSONModel
, пишем их модификатор <Ignore
> или, по ситуации, (readonly)
.
Выводы
Концепция «атомов» действительно сработала и позволила создать хорошую масштабируемую архитектуру мобильного банка. Код операций очень простой для понимания и модификации, и его поддержка, при сохранении исходной идеи, будет занимать O(N) времени. А не экспонента, в которую скатываются многие проекты.
Минус данной реализации состоит в том, что отображение сделано по-простому через UITableView
, на смартфонах это работает, а под планшеты, в случае «продвинутого» дизайна, нужно переделать View и частично Presenter (сразу общее решение для смартфонов и планшетов).
А как вы вписываете свою архитектуру в iOS SDK на больших проектах?
Автор: x256