Когда начать? Как разделить приложение на модули? Какие есть варианты?
Этот пост основан на моем опыте попыток выяснить, что может стать отправной точкой для модуляризации существующего проекта iOS-приложения. Я попытался изложить свои мысли, рассуждения и советы, которые могли бы помочь другим разработчикам на этом пути. Может быть сложно запустить модульный механизм в существующем проекте, поэтому надеюсь, что эта статья вдохновит вас.
Введение
Смысл
К модуляризации приложений можно подходить с разных сторон в зависимости от вариантов использования и личных предпочтений. Я думаю, что в идеале каждый модуль должен быть максимально независимым и зависимости, которые нужны модулю, должны быть доступны через интерфейс (он же протокол) не напрямую, а конкретные реализации должны внедряться через внедрение зависимостей. Не всегда возможно или практично скрыть реализацию через интерфейс (он же протокол), но тем не менее это должно быть целью.
Еще одна важная вещь заключается в том, что модуль должен быть автономным, то есть все функции, связанные с модулем, должны находиться внутри модуля, а не распределяться по разным модулям. Например, модуль адаптации должен иметь весь поток внутри модуля, все сетевые запросы и модели ответов, а также события отслеживания аналитики, где сеть и аналитика используются через интерфейс (также известный как протокол) и внедряется конкретная реализация.
Иногда я вижу, что это делается наоборот, когда сетевой модуль имеет конкретные модели ответов, а модуль аналитики имеет конкретные события, необходимые для конкретной функции, и это нарушает независимость модуля и самодостаточную идею.
Начало
Есть много причин, по которым разработчики хотят, чтобы приложение было модульным: более быстрые инкрементальные сборки, разделение задач, изменения кода влияют на прозрачность и т. д. Но, к сожалению, нет конкретного способа сделать приложение модульным, и разработчики оставили площадку для игр. импровизация и кусочки головоломки странной формы, которые не обязательно подходят друг другу так, как ожидалось.
Независимый от iOS код можно извлечь в отдельный проект (или цель), который создает фреймворк. Это полезно знать, но мало что дает в попытке понять, как разбить приложение на небольшие независимые модули.
Когда начать?
Обычно есть уже существующий проект, который стал слишком большим, и его нужно сломать. Вот пример того, как к этому можно подойти.
Создайте структуру UIComponents. Существует множество цветов, пользовательских шрифтов, представлений пользовательского интерфейса, расширений, связанных с созданием пользовательского интерфейса, и прочего, что необходимо для создания функции. Таким образом, перемещение его части в качестве начального шага было бы наверняка полезным. Обычно хорошей идеей является скрытие реализации за интерфейсом (также известным как протокол), но с пользовательским интерфейсом это нецелесообразно, потому что это слишком много разных маленьких и больших частей.
POC извлечения небольших функций в модуль — это хороший способ получить представление о том, как будет работать модульность, и определить основные болевые точки. Выберите небольшую функцию, которая не имеет большого количества зависимостей от других функций, и попытайтесь извлечь ее в виде отдельного выделенного функционального модуля. Небольшая функция должна иметь несколько экранов, сеть и аналитику. Эта функция POC будет зависеть от компонентов пользовательского интерфейса, но, тем не менее, это будут разные вещи, связанные с пользовательским интерфейсом, которые отсутствуют и должны быть перемещены.
Вот простой план действий, которому можно следовать:
- Создайте проект инфраструктуры функций и добавьте его в качестве подпроекта в основной проект.
- Скопируйте файлы, связанные с функцией, в проект функции
- Создайте файл TEMP.swift и скопируйте классы, протокол и т. д. из основного проекта, пока не будет построена структура функций. Реализация скопированного кода может быть закомментирована или заглушена. Основная цель этого TEMP-файла — создать структуру функций и получить полное представление о том, что необходимо.
- Создайте структуру функций, повторив шаг 3.
- Расширьте UIComponents с отсутствующим кодом, связанным с пользовательским интерфейсом. Скопированный/заглушенный код, связанный с пользовательским интерфейсом, из TEMP.swift можно удалить.
- Создайте базовую структуру. На этом этапе код, связанный с пользовательским интерфейсом, должен находиться в UIComponents, но сетевой, аналитический и другой код все еще находится в TEMP.swift, и его необходимо извлечь. Чтобы создать некую базовую структуру для хранения тех зависимостей, которые не связаны с пользовательским интерфейсом. Ключевым моментом здесь является то, что сеть, аналитика и другие необходимые зависимости будут скрыты в основной структуре под интерфейсами (также известными как протоколы) и внедрены через внедрение зависимостей из основного приложения.
- Замените код функции в основном приложении функциональной структурой. На этом этапе функциональная структура должна работать и использоваться в основном приложении. Таким образом, код функции должен быть удален из основного приложения и заменен кодом структуры функций.
Вышеуказанные шаги должны стать отправной точкой на пути к модуляризации приложений. Кроме того, это должно привести к трем запросам на слияние:
- Pull Request: для расширения UIComponents. Большинство изменений — это перенос существующих файлов в UIComponents и добавление общедоступных аннотаций.
- Запрос на извлечение: для основной платформы (сети, аналитика и другие вещи, не связанные с пользовательским интерфейсом). Конкретная реализация должна быть введена из основного приложения.
- Запрос на вытягивание: для функциональной платформы/модуля, который зависит от UIComponents и основных платформ и используется в основном приложении.
Предостережения
Скрытие реализации за интерфейсом (он же протокол) должно быть направлением для тех фреймворков/модулей, которые будут созданы, даже если это непростая задача. Для функциональных модулей это может быть нецелесообразно из-за большого объема кода, доступного для основного приложения. Но для этих базовых модулей (ядро, сеть, аналитика и т. д.) это должно быть целью.
public protocol Networking {
func perform<Response: Decodable>(request: Requestable, result: @escaping (Result<Response, StandardError>) -> Void)
}
public protocol AnalyticsTracking {
func track(event: String,
properties: [String: AnalyticsPropertyValue])
func track(_ event: AnalyticsEvent)
func setOnce(properties: [String: AnalyticsPropertyValue])
func set(properties: [String: AnalyticsPropertyValue])
func increment(properties: [String: AnalyticsPropertyValue])
}
Интерфейс аналитики достаточно мал, но не все вещи можно легко абстрагировать из-за конкретной реализации, обеспечиваемой используемым трекером аналитики. Самая большая проблема заключается в свойствах событий, значения которых могут быть Any, но это вызовет неприятную реализацию моста в основном приложении. Но есть обходной путь, который на первый взгляд покажется немного странным, но значительно упростит соединение.
analyticsTracker.track(
event: "Complete Payment",
properties:
"Amount": .value(payment.amount), // Double
"Product Name": .value(payment.productName), // String
]
)
Абстракция значений свойств события:
public enum AnalyticsPropertyValue {
case string(String)
case strings([String])
case int(Int)
case date(Date)
public static func value(_ value: String) -> Self {
return .string(value)
}
public static func value(_ value: [String]) -> Self {
return .strings(value)
}
public static func value(_ value: Int) -> Self {
return .int(value)
}
public static func value(_ value: Date) -> Self {
return .date(value)
}
}
Работа в сети тоже немного затруднительна, потому что в идеале вы хотите иметь автономные функциональные модули с моделями запросов и ответов и обработкой ошибок внутри модуля. Чтобы достичь этого, необходимо пойти на некоторые компромиссы, особенно в том, как выглядят ошибки и как они обрабатываются. Чтобы обобщить ошибки, вам нужно иметь какой-то общий объект ошибки, например, StandardError, который будет иметь код (ошибки), источник (идентификатор микросервиса) и необработанный ответ (на всякий случай). Этого должно быть достаточно для реализации правильной обработки ошибок в функциональном модуле.
public struct StandardError: Error {
public let code: String
public let origin: String
public let rawResponse: RawResponse
}
/// Raw response from URLSession
public struct RawResponse {
public let response: URLResponse?
public let data: Data?
public let error: Error?
}
App-Bridging-Header.h очень удобно добавлять эти фреймворки/модули в основное приложение, потому что вам не нужно импортировать их в каждое место, где они используются. Это очень помогает, особенно при перемещении кода, связанного с пользовательским интерфейсом, в UIComponents, потому что почти весь код пользовательского интерфейса в приложении нуждается в чем-то из UIComponents, и добавление этого импорта во многих местах довольно быстро станет неприятным.
Внедрение зависимостей — хороший способ обеспечить конкретную реализацию за интерфейсом (он же протокол). Его можно довольно легко реализовать с помощью локатора сервисов и оболочки свойств.
Как внедренные зависимости используются через оболочку свойств:
@Injected private var analyticsTracker: AnalyticsTracking
@Injected private var apiWorker: Networking
Пример реализации обёртки свойств:
@propertyWrapper public final class Injected<Service> {
private let serviceLocator = ServiceLocator.shared
private var service: Service?
public init() {}
public var wrappedValue: Service {
if let service = self.service {
return service
} else {
let service: Service = serviceLocator.resolve()
self.service = service
return service
}
}
}
Пример сервисного локатора:
final class ServiceLocator {
static let shared = ServiceLocator()
init() {}
private var container: [ObjectIdentifier: Any] = [:]
func register<T>(_ service: @escaping () -> T) {
container[ObjectIdentifier(T.self)] = service
}
func clearServices() {
container = [:]
}
func resolve<T>() -> T {
guard let service = container[ObjectIdentifier(T.self)] as? () -> T else {
preconditionFailure("Could not locate \(T.self)")
}
return service()
}
}
Статическое и динамическое связывание — важная концепция, которую необходимо понимать при работе с проектами Xcode. Когда вы создаете проект Framework в Xcode, вы создаете динамически связанный Framework, а когда вы создаете статическую библиотеку, вы создаете статически связанную библиотеку.
На практике динамическое связывание означает, что у вас будет один экземпляр Framework, независимо от того, сколько раз вы будете добавлять его к разным целям. Например, если у вас есть базовые модули, которые представляют собой динамическую структуру и используются во многих функциональных модулях, у вас будет один экземпляр базового модуля, совместно используемый всеми функциональными модулями.
С другой стороны, если у вас есть модуль, который является статической библиотекой. он будет каждый раз копироваться, и у вас будет много его экземпляров. Может показаться, что динамический фреймворк — очевидный выбор, но это не так. Основная причина заключается в том, что динамический фреймворк снижает время запуска приложения, поскольку при запуске приложения необходимо выполнить дополнительное связывание. Вы должны подумать, будет ли новый модуль использоваться более одного раза, потому что, если вы просто по умолчанию используете динамический вариант Framework, вы будете медленно увеличивать время запуска приложения.
Следующие шаги
Когда вы начнете работать с фреймворками UIComponents (код пользовательского интерфейса) и Core (не код пользовательского интерфейса), вы можете начать изучать, как двигаться вперед и как применять модульность в более крупном масштабе в приложении. Чем крупнее функция, тем сложнее перенести ее в модуль/фреймворк. Кроме того, обычно более крупные функции имеют больше зависимостей от других функций, что только добавляет сложности, но, с другой стороны, интересно попытаться решить эти большие головоломки.
Отказ от ответственности: на момент написания этой статьи я не продвинулся дальше UIComponents и основных фреймворков, но хочу поделиться некоторыми соображениями и мыслями, которые могут оказаться полезными. Тем не менее, когда дело сдвинется с места, должна быть и вторая часть.
Что там?
Из того, что я видел, обычно есть две стороны подхода к модуляризации приложений — многоуровневая модульность или гранулярная модуляция или что-то среднее.
Многоуровневая модульность
Основная идея многоуровневой модульности заключается в том, что у вас есть своего рода одноядерный уровень, который используют все функциональные модули. Также у слоя Core могут быть и другие зависимости, но они не видны. Это позволяет нам довольно быстро перемещать большой объем кода из основного приложения в ядро и улучшать добавочные сборки.
Но, в конце концов, он оказывается в массивном ядре (таком же, как массивный ViewController). Таким образом, обычно это краткосрочное решение, которое требует пересмотра и большей детализации в долгосрочной перспективе.
Гранулярная модульность
Основная идея гранулярной модуляризации состоит в том, чтобы иметь как можно большую гранулярность. Это звучит разумно, но если применить это к крайности, это может привести к взрыву модулей, когда разные модули зависят от других модулей. И хотя это высокомодульное конечное решение, оно имеет высокую степень связанности, поскольку каждый модуль имеет несколько перекрестных зависимостей. Особенно, если модули не раскрывают свою функциональность через интерфейсы и не используется внедрение зависимостей.
Я также иногда вижу модуль Core/Shared в этом подходе модульности, который содержит набор небольших фрагментов общего кода, обычно в форме расширений, которые по отдельности слишком малы, чтобы быть отдельными модулями. Это делается для предотвращения дублирования кода в функциональных модулях, но создает сильную связь крошечных битов в каждом модуле, использующем модуль Core/Shared.
Кроме того, некоторые странные вещи (расширения) могут появиться здесь только потому, что кто-то подумал, что это хорошая идея и может использоваться во многих местах, хотя это только в одном месте. И поскольку Core/Shared не имеет конкретной цели (кроме сбора случайного повторно используемого кода), трудно контролировать, что в него входит.
Видение высокого уровня
Важно иметь общее представление о том, что может быть идеальным решением в вашем конкретном проекте, а затем идти на некоторые компромиссы, чтобы максимально приблизить его к идеалу. Если начать наоборот, то трудно понять, идет ли модульность в правильном направлении, потому что конечная цель слишком абстрактна.
Вот пример того, как можно представить модульное приложение. Основное приложение построено из функциональных модулей, и эти функциональные модули зависят от набора базовых модулей. Базовые модули, такие как сеть, аналитика и т. д., предоставляют только интерфейс (он же протокол), а конкретная реализация внедряется из основного приложения.
Таким образом, в основном эти модули Foundation не имеют фактической реализации внутри себя, они просто создают стандартный интерфейс для того, как сеть, аналитика и т. д. должны выполняться в функциональных модулях. Конечно, это не всегда так идеально. Например, UIComponents — это Foundation-модуль, но он используется напрямую в Feature-модулях, потому что скрыть его под интерфейсом просто практически невозможно.
Взаимозависимые функции
Может показаться, что у вас будет идеальное видение высокого уровня, и вы пойдете и просто сделаете это, но на самом деле это никогда не так. Самая загадочная часть — это те модули функций, которые используются как отдельные функции в приложении, а их части используются в других функциях.
Например, функциональный модуль «Счета и карты» — это отдельная функция в приложении для управления вашими платежными счетами и картами, но средство выбора счета и карты (его часть) используется в других платежных процессах как интеллектуальный компонент пользовательского интерфейса. Это всего лишь один пример, но на самом деле, в зависимости от функций и связей между ними, таких случаев в приложении может быть много.
На момент написания этой статьи у меня не было конкретного решения для взаимозависимых функциональных модулей, но я надеюсь, что во второй части статьи будут некоторые рекомендации, когда модульность приложения будет развиваться.
Последние мысли
Вероятно, не существует универсального решения для модульности приложения, но самое главное — иметь четкое общее видение и свое определение того, что такое модуль. Также полезно знать, какие существуют типы модулей в приложении и как они зависят друг от друга. Кроме того, будет необходимо идти на компромиссы, но, по крайней мере, должно быть ясно, что это компромисс, а не то, как это должно быть сделано.
Тем не менее, какова ваша цель? Просто ускорить инкрементную сборку? Или создавать функциональные модули, над которыми могла бы работать каждая кросс-функциональная команда с минимальным влиянием на другие команды?
В зависимости от этих ответов вы можете решить, как далеко вы хотите зайти с модуляризацией. Может быть, достаточно одного модуля Core; может быть, 2–3 достаточно, и функции могут жить в основной кодовой базе приложения. Может быть, проект настолько велик, а количество команд так велико, что вы хотите потратить время на функциональные модули.
Ничто не является бесплатным, и вам придется заплатить цену в любом случае. Если вы решите, что немодульное приложение на данный момент достаточно хорошо, вам придется заплатить цену более высокого риска, чтобы повлиять на другие области приложения и внести критические изменения из-за возможности вносить изменения проще. Если вы решите сделать приложение модульным (на любом уровне модуляризации), вам придется заплатить цену за то, что вносить изменения будет немного сложнее, но больше контроля над тем, на что это влияет.
Пример проекта
Вот пример проекта того, как реализация начальной точки с UIComponents и Core framework выглядит в реальности и как все сшивается вместе.
https://github.com/Jamagas/iOS_App_Modularisation_Starting_Point_Example