Композитный «datasource»-объект и элементы функционального подхода

    Как-то раз я (ну ладно, даже не я) столкнулся с задачей добавить в UICollectionView с определенным типом ячеек одну ячейку совершенно другого типа, причем делать это лишь в особенном случае, который обрабатывается «выше» и никак не зависит от UICollectionView напрямую. Задача эта породила, если мне не изменяет память, пару-тройку уродливых if-else-блоков внутри методов UICollectionViewDataSource и UICollectionViewDelegate, которые благополучно осели в «production»-коде и, наверное, никуда оттуда уже не денутся.

    В рамках упомянутой задачи смысла продумывать какое-либо более элегантное решение, тратить на это время и «думательную» энергию, не было. Тем не менее эта история мне запомнилась: я размышлял над тем, чтобы попробовать реализовать некий «datasource»-объект, который бы мог составляться из любого числа других «datasource»-объектов в единое целое. Решение, очевидно, должно быть обобщенным, подходить для любого числа составляющих (включая ноль и один) и не зависеть от конкретных типов. Оказалось, что это не только реально, но и не слишком сложно (хотя сделать код при этом еще и «красивым» – немного труднее).

    Я покажу то, что у меня получилось на примере UITableView. При желании, написать аналогичный код для UICollectionView затруднений составить не должно.

    «Идея всегда важнее своего воплощения»


    Этот афоризм принадлежит великому автору комиксов Алану Муру («Хранители», «V – значит вендетта», «Лига выдающихся джентльменов»), но это не совсем про программирование, правда?

    Основная идея моего подхода – это хранить массив объектов UITableViewDataSource, возвращать их суммарное количество секций и иметь возможность при обращении к секции определить, какому из оригинальных «datasource»-объектов перенаправить это обращение.

    Протокол UITableViewDataSource уже имеет необходимые методы для получения количества секций, строк и т.п., но, к сожалению, в данном случае пользоваться ими я нашел крайне неудобным из-за необходимости передачи в качестве одного из аргументов ссылки на конкретный экземпляр UITableView. Поэтому я решил расширить стандартный протокол UITableViewDataSource парой дополнительных простых членов:

    protocol ComposableTableViewDataSource: UITableViewDataSource {
        var numberOfSections: Int { get }
        func numberOfRows(for section: Int) -> Int
    }
    

    А композитный «datasource» получился простым классом, который реализует требования UITableViewDataSource и инициализируется всего с одним аргументом – набором конкретных экземпляров ComposableTableViewDataSource:

    final class ComposedTableViewDataSource: NSObject, UITableViewDataSource {
    
        private let dataSources: [ComposableTableViewDataSource]
    
        init(dataSources: ComposableTableViewDataSource...) {
            self.dataSources = dataSources
            super.init()
        }
    
        private override init() {
            fatalError("ComposedTableViewDataSource: Initializer with parameters must be used.")
        }
    
    }
    

    Теперь остается только написать реализации всех методов протокола UITableViewDataSource таким образом, чтобы они ссылались на методы соответствующих составляющих.

    «Это было правильное решение. Моё решение»


    Эти слова принадлежали Борису Николаевичу Ельцину, первому президенту Российской Федерации, и не очень относятся к тексту ниже, просто они мне понравились.

    Правильным решением мне показалось воспользоваться функциональными возможностями языка Swift, и это действительно оказалось удобно.

    Для начала реализуем метод, возвращающий количество секций – это несложно. Как уже упоминалось выше, нам всего лишь нужно полное количество всех секций составляющих:

    func numberOfSections(in tableView: UITableView) -> Int {
        // Default value if not implemented is "1".
        return dataSources.reduce(0) { $0 + ($1.numberOfSections?(in: tableView) ?? 1) }
    }
    

    (Пояснять синтаксис и значение стандартных функций я не буду. Если это требуется, интернет пестрит неплохими вводными статьями на тему. А еще могу посоветовать довольно хорошую книгу.)

    Бегло просмотрев все методы UITableViewDataSource, можно заметить, что в качестве аргументов они принимают лишь ссылку на таблицу и значение либо номера секции, либо соответствующего строке IndexPath. Напишем несколько помощников, которые будут нам полезны при реализации всех остальных методов протокола.

    Во-первых, все задачи можно свести к «generic»-функции, которая принимает в качестве аргументов ссылку на конкретный ComposableTableViewDataSource и значение номера секции либо IndexPath. Для удобства и краткости назначим типам этих функций псевдонимы. Плюс, для дополнительной удобочитаемости, я предлагаю объявить псевдоним и для номера секции:

    private typealias SectionNumber = Int
    
    private typealias AdducedSectionTask<T> = (_ composableDataSource: ComposableTableViewDataSource, _ sectionNumber: SectionNumber) -> T
    
    private typealias AdducedIndexPathTask<T> = (_ composableDataSource: ComposableTableViewDataSource, _ indexPath: IndexPath) -> T
    

    (Выбранные имена я объясню чуть ниже.)

    Во-вторых, реализуем простую функцию, которая по номеру секции ComposedTableViewDataSource определяет конкретный ComposableTableViewDataSource и соответствующий номер его секции:

    private func decompose(section: SectionNumber) -> (dataSource: ComposableTableViewDataSource, decomposedSection: SectionNumber) {
        var section = section
        var dataSourceIndex = 0
        for (index, dataSource) in dataSources.enumerated() {
            let diff = section - dataSource.numberOfSections
            dataSourceIndex = index
            if diff < 0 { break } else { section = diff }
        }
            
        return (dataSources[dataSourceIndex], section)
    }
    

    Возможно, если подумать чуть дольше моего, реализация получится более элегантной и менее прямолинейной. Например, коллеги сразу предложили мне реализовать в этой функции двоичный поиск (предварительно, например, при инициализации, составив индекс числа секций – простой массив из целых чисел). Или вовсе потратить немного времени на составление и памяти на хранение таблицы соответствий номеров секций – зато потом вместо постоянного использования метода с временной сложностью O(n) или O(log n) можно будет получать результат ценой O(1). Но я решил воспользоваться советом великого Дональда Кнута не заниматься преждевременной оптимизацией без видимой надобности и соответствующих замеров. Да и не об этом статья.

    И, наконец, функции, которые принимают обозначенные выше AdducedSectionTask и AdducedIndexPathTask и «перенаправляют» их конкретным экземплярам ComposedTableViewDataSource:

    private func adduce<T>(_ section: SectionNumber, 
                           _ task: AdducedSectionTask<T>) -> T {
        let (dataSource, decomposedSection) = decompose(section: section)
        return task(dataSource, decomposedSection)
    }
    
    private func adduce<T>(_ indexPath: IndexPath,
                           _ task: AdducedIndexPathTask<T>) -> T {
        let (dataSource, decomposedSection) = decompose(section: indexPath.section)
        return task(dataSource, IndexPath(row: indexPath.row, section: decomposedSection))
    }
    

    А теперь можно объяснить выбранные мной названия для этих всех функций. Все просто: они отражают функциональный стиль именования. Т.е. мало что означают буквально, зато звучат внушительно.

    Последние две функции выглядят почти как близнецы, но, немного подумав, я оставил попытки избавиться от дублирования кода, потому что это приносило больше неудобств, чем преимуществ: пришлось выводить или передавать функции преобразования к номеру секции и обратно в исходный тип. К тому же вероятность переиспользования этого обобщенного подхода стремится к нулю.

    Все эти приготовления и помощники дают неимоверное преимущество в реализации, собственно, методов протокола. Методы конфигурации таблицы:

    func tableView(_ tableView: UITableView,
                   titleForHeaderInSection section: Int) -> String? {
        return adduce(section) { $0.tableView?(tableView, titleForHeaderInSection: $1) }
    }
        
    func tableView(_ tableView: UITableView,
                   titleForFooterInSection section: Int) -> String? {
        return adduce(section) { $0.tableView?(tableView, titleForFooterInSection: $1) }
    }
        
    func tableView(_ tableView: UITableView,
                   numberOfRowsInSection section: Int) -> Int {
        return adduce(section) { $0.tableView(tableView, numberOfRowsInSection: $1) }
    }
        
    func tableView(_ tableView: UITableView, 
                   cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        return adduce(indexPath) { $0.tableView(tableView, cellForRowAt: $1) }
    }
    

    Вставка и удаление строк:

    func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        return adduce(indexPath) { $0.tableView?(tableView, commit: editingStyle, forRowAt: $1) }
    }
        
    func tableView(_ tableView: UITableView, 
                   canEditRowAt indexPath: IndexPath) -> Bool {
        // Default if not implemented is "true".
        return adduce(indexPath) { $0.tableView?(tableView, canEditRowAt: $1) ?? true }
    }
    

    Аналогичным способом можно реализовать поддержку заголовков индекса секций. В этом случае вместо номера секции оперировать придется индексом заголовка. Также, скорее всего, будет полезно для этого добавить дополнительное поле в протокол ComposableTableViewDataSource. Я оставил эту часть за пределами материала.

    «Невозможное сегодня станет возможным завтра»


    Это слова российского ученого Константина Эдуардовича Циолковского, основоположника теоретической космонавтики.

    Во-первых, представленное решение не поддерживает перетаскивание строк. Изначальный замысел включал поддержку перетаскивания в пределах одного из составляющих «datasource»-объектов, но, к сожалению, это невозможно реализовать при помощи только лишь UITableViewDataSource. Методы этого протокола определяют, можно ли «перетаскивать» конкретную строку и получают «коллбэк» по окончании перетаскивания. А обработка самого события подразумевается внутри методов UITableViewDelegate.

    Во-вторых, что важнее, необходимо продумать механизмы обновления данных на экране. Думаю, реализовать это можно, объявив протокол делегата ComposableTableViewDataSource, методы которого будут реализовываться ComposedTableViewDataSource и получать сигнал о том, что исходный «datasource» получил обновление. Остаются открытыми два вопроса: как внутри ComposedTableViewDataSource надежно определить, какой именно ComposableTableViewDataSource изменился и каким именно образом – это отдельная и не самая тривиальная задача, но имеющая ряд решений (например, такое). И, конечно, понадобится протокол делегата ComposedTableViewDataSource, методы которого будут вызываться при обновлении составного «datasource» и реализовываться клиентским типом (например, контроллером или вью-моделью).

    Надеюсь со временем исследовать эти вопросы лучше и покрыть их во второй части статьи. А пока, тешусь, вам было любопытно почитать об этих экспериментах!

    P.S.


    Буквально на днях мне пришлось залезть в код упомянутый во вступлении для его модификации: понадобилось поменять местами ячейки тех двух типов. Если кратко, то пришлось помучиться и «подебажить» постоянно возникающие в разных местах Index out of bounds. При использовании описанного подхода понадобилось бы лишь поменять местами два «datasource»-объекта в массиве, передаваемом в качестве аргумента инициализатора.

    Ссылки:
    Playgroud с полным кодом и примером
    Мой Twitter
    Поделиться публикацией

    Комментарии 1

    Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

    Самое читаемое