Site icon AppTractor

Начинаем работу над модуляризацией iOS-приложения

Этот пост основан на моих попытках выяснить, что может стать отправной точкой для модуляризации существующего iOS-приложения. Я попытался изложить свои мысли, рассуждения и советы, которые могли бы помочь другим разработчикам на этом пути. Может быть сложно раскатать модульность в существующем проекте, поэтому надеюсь, что эта статья вдохновит вас.

Главная идея

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

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

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

Начало

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

Независимый от iOS код можно извлечь в отдельный проект (или цель), из которого сделать фреймворк. Это полезно знать, но мало что дает в попытке понять, как разбить приложение на небольшие независимые модули.

Когда начать?

Обычно есть уже существующий проект, который стал слишком большим, и его нужно сломать. Вот пример того, как к этому можно подойти.

Создайте фреймворк UIComponents. Существует множество цветов, пользовательских шрифтов, UI представлений, расширений, связанных с созданием UI, и прочего, что необходимо для создания фич. Таким образом, перемещение части этого в качестве начального шага было бы наверняка полезным. Обычно хорошей идеей является скрытие реализации за интерфейсом (протоколом), но с пользовательским интерфейсом это нецелесообразно, потому что там слишком много разных маленьких и больших частей.

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

Вот простой план действий, которому можно следовать:

  1. Создайте проект фреймворка фичи и добавьте его в качестве подпроекта в основной проект.
  2. Скопируйте файлы, связанные с фичей, в проект.
  3. Создайте файл TEMP.swift и скопируйте классы, протокол и т.д. из основного проекта, пока не будет создан фреймворк функции. Реализация скопированного кода может быть закомментирована или заглушена. Основная цель этого TEMP-файла — создать фреймворк и получить полное представление о том, что необходимо.
  4. Создайте билд фреймворка фичи, повторяя шаг 3.
  5. Расширьте UIComponents отсутствующим кодом, связанным с пользовательским интерфейсом. Скопированный/заглушенный код, связанный с пользовательским интерфейсом, из TEMP.swift можно удалить.
  6. Создайте Core фреймворк. На этом этапе код, связанный с пользовательским интерфейсом, должен находиться в UIComponents, но сетевой, аналитический и другой код все еще находится в TEMP.swift, и его необходимо извлечь. Тут нам и понадобится создать некий базовый (Core) фреймворк для хранения тех зависимостей, которые не связаны с пользовательским интерфейсом. Ключевым моментом здесь является то, что сеть, аналитика и другие необходимые зависимости будут скрыты в Core фреймворке за интерфейсами (протоколами) и внедряться через внедрение зависимостей из главного приложения.
  7. Замените код фичи в основном приложении фреймворком. На этом этапе функциональная структура должна работать и использоваться в основном приложении. Таким образом, код функции должен быть удален из основного приложения и заменен кодом фреймворка.

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

Предостережения

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

Интерфейс аналитики достаточно мал, но не все вещи можно легко абстрагировать из-за конкретной реализации, трекера аналитики. Самая большая проблема заключается в свойствах событий, значения которых могут быть Any, но которые вызывают неприятную реализацию в основном приложении. Есть обходной путь, который на первый взгляд покажется немного странным, но значительно упростит создание подключений.

analyticsTracker.track(
    event: "Complete Payment", 
    properties: 
        "Amount": .value(payment.amount), // Double
        "Product Name": .value(payment.productName), // String
    ]
)

Абстракция свойств события:

Выделение в модули сетевой работы тоже немного затруднительно, потому что в идеале вы хотите иметь автономные функциональные модули с моделями запросов и ответов и обработкой ошибок внутри модуля. Чтобы достичь этого, необходимо пойти на некоторые компромиссы, особенно в том, как выглядят ошибки и как они обрабатываются. Чтобы обобщить ошибки, вам нужно иметь какой-то общий объект ошибки, например, StandardError, который будет иметь код (ошибки), источник (идентификатор микросервиса) и необработанный ответ (на всякий случай). Этого должно быть достаточно для реализации правильной обработки ошибок в функциональном модуле.

С помощью App-Bridging-Header.h очень удобно добавлять эти фреймворки/модули в основное приложение, потому что вам не нужно импортировать их в каждое место, где они используются. Это очень помогает, особенно при перемещении кода, связанного с пользовательским интерфейсом, в UIComponents, потому что почти весь код пользовательского интерфейса в приложении нуждается в чем-то из UIComponents, и добавление его импорта во многих местах довольно быстро надоест.

Внедрение зависимостей — хороший способ обеспечить конкретную реализацию за интерфейсом (протоколом). Его можно довольно легко реализовать с помощью локатора сервисов и обертки свойств.

Как внедренные зависимости используются через property wrapper:

@Injected private var analyticsTracker: AnalyticsTracking
@Injected private var apiWorker: Networking

Пример реализации обёртки свойств:

Пример локатора сервисов:

Статическое и динамическое связывание — важная концепция, которую необходимо понимать при работе с проектами в Xcode. Когда вы создаете проект Framework в Xcode, вы создаете динамически связанный фреймворк, а когда вы создаете Static Library, вы создаете статически связанную библиотеку.

На практике динамическое связывание означает, что у вас будет один экземпляр фреймворка, независимо от того, сколько раз вы будете добавлять его к разным таргетам. Например, если у вас есть Core модули, которые представляют собой динамический фреймворк и используются во многих функциональных модулях, у вас будет один экземпляр Core модуля, совместно используемый всеми функциональными модулями.

С другой стороны, если у вас есть Module, который является статической библиотекой, он будет каждый раз копироваться, и у вас будет много его экземпляров. Может показаться, что динамический фреймворк — очевидный выбор, но это не так. Основная причина заключается в том, что динамический фреймворк снижает время запуска приложения, поскольку при запуске приложения необходимо выполнить дополнительное линкование. Вы должны подумать, будет ли новый модуль использоваться более одного раза, потому что, если вы просто по умолчанию используете динамический вариант фреймворка, вы будете постепенно увеличивать время запуска приложения.

Следующие шаги

Когда вы начнете работать с фреймворками UIComponents (код пользовательского интерфейса) и Core (не код пользовательского интерфейса), вы сможете начать изучать, как двигаться вперед и как применять модульность  в приложении в большем масштабе. Чем крупнее функция, тем сложнее перенести ее в модуль/фреймворк. Кроме того, обычно более крупные функции имеют больше зависимостей от других функций, что только добавляет сложности, но, с другой стороны, интересно попытаться решить эти головоломки.

Отказ от ответственности: на момент написания этой статьи я не продвинулся дальше UIComponents и основных фреймворков, но хочу поделиться некоторыми соображениями и мыслями, которые могут оказаться полезными. Тем не менее, когда дело сдвинется с места, должно быть выйдет и вторая часть.

Что думают другие?

Из того, что я видел, обычно есть две стороны подхода к модуляризации приложений — многоуровневая модульность или гранулярная модуляция или что-то среднее.

Многоуровневая модульность

Основная идея многоуровневой модульности заключается в том, что у вас есть своего рода одноядерный уровень, который используют все функциональные модули. Также у слоя Core могут быть и другие зависимости, но они не видны. Это позволяет нам довольно быстро перемещать большой объем кода из основного приложения в ядро и улучшать инкрементальные сборки.

Но, в конце концов, все заканчивается массивным Core (таком же, как массивный ViewController). Таким образом, обычно это краткосрочное решение, которое требует пересмотра и большей детализации в долгосрочной перспективе.

Гранулярная модульность

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

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

Кроме того, здесь могут проявиться некоторые странные вещи (расширения) только потому, что кто-то подумал, что это хорошая идея и их можно использоваться во многих местах, хотя на самом деле они нужны только в одном месте. И поскольку Core/Shared не имеет конкретного назначения (кроме сбора повторно используемого кода), трудно контролировать, что в него входит.

Видение высокого уровня

Важно иметь общее представление о том, что может быть идеальным решением в вашем конкретном проекте, а затем идти на некоторые компромиссы, чтобы максимально приблизить его к идеалу. Если двигаться наоборот, то трудно понять, идет ли модульность в правильном направлении, потому что конечная цель слишком абстрактна.

Вот пример того, как выглядит правильно модульное приложение. Основное приложение построено из фиче модулей, и эти функциональные  модули зависят от набора базовых Foundation модулей. Базовые модули, такие как сеть, аналитика и т.д., предоставляют только интерфейс (протокол), а конкретная реализация внедряется из основного приложения.

Таким образом, в основном эти Foundation модули не имеют фактической реализации внутри себя, они просто создают стандартный интерфейс для сети, аналитики и т.д., которые должны работать в фиче модулях. Конечно, это не всегда так идеально. Например, UIComponents — это Foundation-модуль, но он используется напрямую в Feature-модулях, потому что скрыть его за интерфейсом практически невозможно.

Взаимозависимые функции

Может показаться, что когда у вас будет идеальное высокоуровневое видение, вы пойдете и просто сделаете это, но на самом деле это никогда так не работает. Самая загадочная часть — это те фиче модули, которые используются как отдельные функции в приложении, а их части используются в других функциях.

Например, функциональный модуль «Счета и карты» — это отдельная функция в приложении для управления вашими платежными счетами и картами, но средство выбора счета и карты (его часть) используется в других платежных процессах как интеллектуальный компонент пользовательского интерфейса. Это всего лишь один пример, но на самом деле, в зависимости от функций и связей между ними, таких случаев в приложении может быть много.

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

Последние мысли

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

Тем не менее, какова ваша цель? Просто ускорить инкрементную сборку? Или создавать функциональные модули, над которыми могла бы работать каждая кросс-функциональная команда с минимальным влиянием на другие команды?

В зависимости от этих ответов вы можете решить, как далеко вы хотите зайти с модуляризацией. Может быть, достаточно одного модуля Core; может быть, достаточно двух или трех, а фичи могут жить в основной кодовой базе приложения. Может быть, проект настолько велик, а количество команд так велико, что вы хотите инвестировать время в создание фиче модулей.

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

Пример проекта

Вот пример того, как реализуется начальный подход с UIComponents и Core фреймворком и как все работает вместе: https://github.com/Jamagas/iOS_App_Modularisation_Starting_Point_Example.

Пример: как работает новая система модульности в Авито

Exit mobile version