Мобильный банк для iOS: добавляем блочную архитектуру к Cocoa MVC

в 20:40, , рубрики: ios development, iOS разработка, swift, архитектура, мобильный банк, ооп, Проектирование и рефакторинг, разработка мобильных приложений, разработка под iOS

Если вы пишете приложение мобильного банка для iOS, какие у вас приоритеты? Думаю, их два:

  1. Надёжность;
  2. Скорость внесения изменений.

Ситуация такова, что нужно уметь вносить изменения (и в частности выкатывать новые банковские продукты) действительно быстро. Но при этом не скатываться в индусокод и копипаст (см. пункт 1). Всё это при том, что приложение действительно огромное по функционалу, по крайней мере в задумке (банки хотят намного больше, чем умеют). Соответственно, во многих случаях это проекты на десятки человеко-лет. Те, кто участвовал в таких проектах, уже наверно поняли, что задача нетривиальная, и школьные знания тут не помогут.

Что же делать?

Сразу понятно, что это задача по архитектуре. Прежде чем писать код, я увидел простую и красивую блочную архитектуру фронтенда нашей ДБО. Не видел раньше подобного в мобильных приложениях, но в итоге сделал аналогичное решение, вполне эффективное и масштабируемое, в рамках iOS SDK, без добавления массивных фреймворков. Хочу поделиться основными моментами.

Да, про все эти рамки SDK, про эппловские косяки особенности паттерна MVC и про быдлокод в официальной документации знают все, кто пытался писать и поддерживать серьёзные приложения (или хотя бы читал Хабр) — не буду повторяться. Здесь также не будет основ программирования, файлов с примерами и фотографий с котиками.

Мобильный банк для iOS: добавляем блочную архитектуру к Cocoa MVC - 1
Первая гуглокартинка по запросу «advanced architecture»

Суть решения

Операции состоят из атомов.
Операция — это интерфейс для совершения конкретного действия в АБС. Например, перевод между своими счетами.

Атом — это стабильная «капсула» из MVC, некий кирпичик, из которого можно строить дома любого размера и формы. Каждый кирпичик имеет свою сущность в UI: надпись, поле для ввода, картинка. Каждый значимый UI-блок инкапсулируется в такой MVC-кирпичик. Например, UISegmentedControl инкапсулируется в SegmentedAtom.

Эти MVC-кирпичики пишутся один раз. Дальше каждая операция строится из этих кирпичиков. Положить кирпичик — это одна строчка. Одна строчка! Дальше получить значение — это опять одна строчка. Всё остальное решается наследованием и полиморфизмом в подклассах Atom. Задача — максимально упростить код самих операций, т.к. именно они могут сильно меняться. Кирпичики принципиально не меняются (или даже вообще не меняются).

Атом может быть и более сложным элементом. Он может инкапсулировать некую логику и дочерние ViewController'ы. Например, атом для выбора счёта из списка (причём по фильтру из контекста). Главное, чтобы вся сложность осталась внутри атома. Снаружи он остаётся таким же простым в использовании.

Мобильный банк для iOS: добавляем блочную архитектуру к Cocoa MVC - 2
Мою концепцию уже используют в строительстве

Для примера, операция отправки платёжного поручения «по быстрой форме» (из кода скрыл все моменты, связанные с безопасностью, и да, права на код принадлежат ООО «Цифровые Технологии Будущего»):

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

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js