Pull to refresh

Управление зависимостями в iOS-приложения на Swift со спокойствием

Reading time 10 min
Views 23K
иконка библиотекиВсем доброго времени суток. В наше нелегкое время постоянно приходится сталкиваться со стрессовыми ситуациями и написание программного кода тому не исключение. Все справляются со стрессом по разному: кто-то идет в бар, кто-то наоборот медитирует в тишине, но каждый человек хочет, чтобы этого стресса было как можно меньше, и старается избегать заведомо стрессовых ситуаций.

Начав писать на Swift, мне пришлось столкнуться с многими проблемами, и одна из них — отсутствие конкуренции у IoC контейнеров на этом языке. По сути их всего два: Typhoon и Swinject. Swinject имеет мало возможностей, а Typhoon написан для Obj-С, что является проблемой, и работать с ним для меня оказалось большим стрессом.

И тут Остапа понесло я решил написать свой IoC контейнер для Swift, что из этого получилось читать под катом:

Итак, знакомьтесь — DITranquillity, IoC контейнер для iOS на Swift, интегрированный со Storyboard.

Интересная история про название — после сотни разных идей, остановился на «спокойствие». При придумывании названия я отталкивался от того, что основной причиной написание IoC контейнера был Typhoon. Вначале были мысли, назвать библиотеку стихийным бедствием посильнее тайфуна, но понял, что надо думать по другому: тайфун — это стресс, а моя библиотека должна обеспечить обратное, то есть спокойствие.

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

Любители Typhoon, возможно, слегка расстроятся, но, мой взгляд, на именование некоторых сущностей отличается от взгляда тайфуна. Он такой же, как у Autofac, но с учетом особенностей языка.

Особенности


Начну с описания особенностей библиотеки:

  • Библиотека работает с чистыми Swift классами. Не надо наследоваться от NSObject и объявлять протоколы как Obj-C, c помощью данной библиотеки можно писать на чистом Swift;
  • Нативность — описание зависимостей происходит на родном языке, что позволяет делать легкий рефакторинг и...
  • Большая часть проверок на этапе компиляции — после Typhoon это может показаться раем, так как многие опечатки обнаруживаются на этапе компиляции, а не во время исполнения. К сожалению, похвастаться тем, что во время исполнения не могут возникнуть ошибки библиотека не может, но часть проблем, будьте уверены, отсекутся;
  • Поддержка всех паттернов Dependency Injection: Initializer Injection, Property Injection и Method Injection. Не знаю, почему это круто, но все пишут об этом;
  • Поддержка циклических зависимостей — библиотека поддерживает много различных вариантов циклических зависимостей, при этом без вмешательства программиста;
  • Интеграция со Storyboard — позволяет внедрять зависимости прямо во ViewController-ы.

А также:

  • Поддержка времени жизни объектов;
  • Указание альтернативных типов;
  • Разрешение зависимостей по типу и имени;
  • Множественная регистрация;
  • Разрешение зависимостей с параметрами инициализации;
  • Короткая запись для разрешения зависимости;
  • Специальные механизмы для «модульности»;
  • Поддержка CocoaPods;
  • Документация на русском (на самом деле, правильнее сказать черновая документация, там ошибок много).

И все это в 1500 строк кода, при этом порядка 400 строк из них, это автоматически генерируемый код, для типизированного разрешения зависимостей с разным количеством параметров инициализации.

И что со всем этим делать?


Кратко


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

// Classes
 
class TaskRepository: TaskRepositoryProtocol {
...
}
 
class LogManager: LoggerProtocol {
...
}
 
class TaskController {
 var logger: LoggerProtocol? = nil
 private let repository: TaskRepositoryProtocol
 
 init(repository:TaskRepository) {
   self.repository = repository
 }
 ...
}
 
// Register
 
let builder = DIContainerBuilder()
builder.register(TaskRepository.self)
 .asType(TaskRepositoryProtocol.self)
 .initializer { TaskRepository() }
 
builder.register(LogManager.self)
 .asType(LoggerProtocol.self)
 .initializer { LogManager(Date()) }
 
builder.register(TaskController.self)
 .initializer { (scope) in TaskController(repository: *!scope) }
 .dependency { (scope, taskController) in taskController.logger = try? scope.resolve() }
 
let container = try! builder.build()
 
// Resolve
 
let taskController: TaskController = container.resolve()

А теперь по-порядку


Базовая интеграция в проект


В отличии от Typhoon, библиотека не поддерживает «автоматическую» инициализацию из plist, или подобные «фичи». В принципе, несмотря на то, что тайфун поддерживает такие возможности, я не уверен в их целесообразности.

Чтобы интегрироваться с проектом, который планируется более-менее крупным, нам надо:

  1. Интегрировать саму библиотеку в проект. Это можно сделать с помощью Cocoapods:

    pod 'DITranquillity'
    

  2. Объявить базовую сборку с помощью библиотеки (опционально):

    import DITranquillity
     
    class AppAssembly: DIAssembly { // Объявляем нашу сборку
     var publicModules: [DIModule] = [ ]
    
     var intermalModules: [DIModule] = [ AppModule() ]
     
     var dependencies: [DIAssembly] = [
       // YourAssembly2(), YourAssembly3() - зависимости на другие сборки
     ]
    }

  3. Объявить базовый модуль (опционально):

    import DITranquillity
     
    class AppModule: DIModule { // Объявляем наш модуль
     func load(builder: DIContainerBuilder) { // Согласно протоколу реализуем метод
       // Регистрируем типы
     }
    }
    

  4. Зарегистрировать типы в модуле (см. первый пример выше).

  5. Зарегистрировать базовую сборку в билдере и собрать контейнер:

    import DITranquillity
     
    @UIApplicationMain
    class AppDelegate: UIResponder, UIApplicationDelegate {
    public func applicationDidFinishLaunching(_ application: UIApplication) {
       ...
       let builder = DIContainerBuilder()
       builder.register(assembly: AppAssembly())
       try! builder.build() // Собираем контейнер
       // Если во время сборки произошла ошибка, то программа упадет, с описанием всех ошибок, которые нужно поправить
     }
    }
    

Storyboard


Следующим этапом, после написания пары классов, создается Storyboard, если его до этого еще не было. Интегрируем его в наши зависимости. Для этого нам нужно будет немного отредактировать базовый модуль:

class AppModule: DIModule {
 func load(builder: DIContainerBuilder) {
   builder.register(UIStoryboard.self)
     .asName("Main") // Даем типу имя по которому мы сможем в будущем его получить
     .instanceSingle() // Говорим что он должен быть единственный в системе
     .initializer { scope in DIStoryboard(name: "Main", bundle: nil, container: scope) }
 
   // Регистрируем остальные типы
 }
}

И изменим AppDelegate:

public func applicationDidFinishLaunching(_ application: UIApplication) {
 ....
 let container = try! builder.build() // Собираем наш контейнер
 
window = UIWindow(frame: UIScreen.main.bounds)
 
 let storyboard: UIStoryboard = try! container.resolve(Name: "Main") // Получаем наш Main storyboard
 window!.rootViewController = storyboard.instantiateInitialViewController()
 window!.makeKeyAndVisible()
 
}

ViewController'ы на Storyboard


И так мы запустили наш код порадовались, что ничего не упало и убедились, что у нас создался наш ViewController. Самое время создать какой-нибудь класс, и внедрить его во ViewController.

Создадим Presenter:

class YourPresenter {
 ...
}

Также нам понадобится дать имя (тип) нашему ViewController, и добавить инъекцию через свойства или метод, но в нашем коде мы воспользуемся инъекцией через свойства:

class YourViewController: UIViewController {
 var presenter: YourPresenter!
 ...
}

Также не забудьте в Storyboard указать, что ViewController является не просто UIViewController, а YourViewController.

И теперь надо зарегистрировать наши типы в нашем модуле:

 func load(builder: DIContainerBuilder) {
   ...
  
   builder.register(YourPresenter.self)
     .instancePerScope() // Говорим, что на один scope нужно создавать один Presenter
     .initializer { YourPresenter() }
 
   builder.register(YourViewController.self)
     .instancePerRequest() // Специальное время жизни для ViewController'ов
     .dependency { (scope, self) in self.presenter = try! scope.resolve() } // Объявляем зависимость
 }

Запускаем программу, и видим что у нашего ViewController’а есть Presenter.

Но, погодите, что за странное время жизни instancePerRequest, и куда делся initializer? В отличии от всех остальных типов ViewController'ы, которые размещаются на Storyboard создаем не мы, а Storyboard, поэтому у нас нет initializer и они не поддерживают инъекцию через метод инициализации. Так как наличие initializer является одним из пунктов проверки при попытке создать контейнер, то нам надо объявить, что данный тип создается не нами, а кем-то другим — для этого существуем модификатор `instancePerRequest`.

Добавляем работу с данными


Дальше проект что-то должен делать и за частую на мобильных устройства приложения получают информацию из сети, обрабатывать её и отображают. Для простоты примера опустим шаг обработки данных и не будем вдаваться в детали получения данных из сети. Просто предположим, что у нас есть протокол Server, с методом `get` и соответственно есть реализация этого протокола. То есть у нас в программе появляется вот такой код:

protocol Server {
 func get(method: String) -> Data?
}
 
class ServerImpl: Server {
 init(domain: String) {
   ...
 }
 func get(method: String) -> Data? {
   ...
 }
}

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

import DITranquillity
 
class ServerModule: DIModule {
 func load(builder: DIContainerBuilder) {
   builder.register(ServerImpl.self)
     .asSelf()
     .asType(Server.self)
     .instanceSingle()
     .initializer { ServerImpl(domain: "https://your_site.com/") }
 }
}

Мы зарегистрировали тип ServerImpl, при этом в программе он будет известен под 2 типами: ServerImpl и Server. Это некоторая особенность поведения при регистрации — если указан альтернативный тип, то основной тип не используется, если не указать этого явно. Также мы указали, что сервер в нашей программе один.

Также слегка модифицируем нашу сборку, чтобы она знала о новом модуле:

class AppAssembly: DIAssembly {
   var publicModules: [DIModule] = [ ServerModule() ]
}

Различие между publicModules и internalModules
Существует два уровня видимости модулей: Internal и Public. Public — означает, что данный модуль будет виден, и в других сборках, которые используют эту сборку, Internal — модуль будет виден только внутри нашей сборки. Правда, надо уточнить, что так как сборка является всего лишь объявлением, то данное правило о видимости модулей распространяется на контейнер, по принципу: все модули из сборок которые были напрямую добавлены в builder, будут включены в собранный им контейнер, а модуля из зависимых сборок включаться в контейнер, только если он объявлены публичными.

Теперь поправим немного Presenter — добавим ему информацию о том, что ему нужен сервер:

class YourPresenter {
 private let server: Server
 
 init(server: Server) {
   self.server = server
 }
}

Мы внедрили зависимость через метод инициализации, но могли сделать это, как и во ViewController’е — через свойства, или метод.

И дописываем регистрацию нашего Presenter — говорим, что мы будем внедрять Server в Presenter:

   builder.register(YourPresenter.self)
     .instancePerScope() // Говорим, что на один scope нужно создавать один Presenter
     .initializer { (scope) in YourPresenter(server: *!scope) }

Тут мы для получения зависимости использовали «быстрый» синтаксис `*!` который является эквивалентом записи: `try! scope.resolve()`

Запускаем нашу программу и видим, что у нашего Presenter'а есть Server. Теперь его можно использовать.

Внедряем логер


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

И так, мы создаем базовый протокол `Logger`, с функцией `log(message: String)` и реализуем несколько реализаций: ConsoleLogger, FileLogger, ServerLogger… Создаем базовый логер который дергает все остальные, и называем его — MainLogger. Дальше мы в те классы, в которых собираемся логировать добавляем строчку на подобии: `var log: Logger? = nil`, и… И теперь нам надо зарегистрировать все те действия, которые мы произвели.

Вначале создаем новый модуль `LoggerModule`:

import DITranquillity
 
class LoggerModule: DIModule {
 func load(builder: DIContainerBuilder) {
   builder.register(ConsoleLogger.self)
     .asType(Logger.self)
     .instanceSingle()
     .initializer { ConsoleLogger() }
 
   builder.register(FileLogger.self)
     .asType(Logger.self)
     .instanceSingle()
     .initializer { FileLogger(file: "file.log") }
 
   builder.register(ServerLogger.self)
     .asType(Logger.self)
     .instanceSingle()
     .initializer { ServerLogger(server: "http://server.com/") }
 
   builder.register(MainLogger.self)
     .asType(Logger.self)
     .asDefault()
     .instanceSingle()
     .initializer { scope in MainLogger(loggers: **!scope) }
 }
}

И не забываем, добавить внедрение нашего логера, во все классы где мы его объявили, к примеру, вот так:

builder.register(YourPresenter.self)
     .instancePerScope() // Говорим, что на один scope нужно создавать один Presenter
     .initializer { scope in try YourPresenter(server: *!scope) }
     .dependency { (scope, obj) in obj.log = *?scope }

И после мы его добавляем в нашу сборку. Теперь стоит разобрать, что мы только что написали.

Вначале мы зарегистрировали 3 наших логера, которые будут доступны по имени Logger — то есть мы осуществили множественную регистрацию. При этом если мы уберем MainLogger, то в программе не будет единственного логера, так как если мы захотим получить один логер, то библиотека не сможет понять какой логер хочет от неё программист. Далее для MainLogger мы делаем две вещи:

  1. Говорим, что это стандартный логер. То есть если нам нужен единственный логер то это будет MainLogger, а не какой-то другой.

  2. В MainLogger передаем список всех наших логеров, за исключением самого себя (это одна из возможностей библиотеки, при множественном разрешении зависимостей исключаются рекурсивные вызовы. Но если мы сделаем тоже самое в блоке dependency, то у нас выдадутся все логеры в том числе MainLogger).Для этого используется быстрый синтаксис `**!`, который является эквивалентом `try! scope.resolveMany()`

Итоги


С помощью библиотеки мы смогли выстроить зависимости между несколькими слоями: Router, ViewController, Presenter, Data. Были показаны такие вещи как: внедрение зависимостей через свойства, внедрение зависимостей через инициализатор, альтернативные типы, модули, немного коснулись времени жизни и сборок.

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

Этот пример доступен по этой ссылке.

Планы


  • Добавление подробного логирования, с возможность указывать внешние функции, в которые приходят логи
  • Поддержка других систем (MacOS, WatchOS)

Альтернативы


  • Typhoon — не поддерживает чистые swift типы, и его синтаксис, на мой взгляд является громоздким
  • Swinject — отсутствие альтернативных типов, и множественной регистрации. Менее развитые механизмы для «модульности», но это хорошая альтернатива

P.S.
На данный момент проект находится, на пререлизном состоянии, и мне бы хотелось, прежде чем давать ему версию 1.0.0 узнать мнения других людей, так как после “официального” выхода, менять что-то кардинально станет сложнее.
Tags:
Hubs:
+13
Comments 3
Comments Comments 3

Articles