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

ікона бібліотекиВсім доброго часу доби. У наш нелегкий час постійно доводиться стикатися зі стресовими ситуаціями і написання програмного коду тому не виключення. Всі справляються зі стресом по різному: хтось йде в бар, хтось навпаки медитує в тиші, але кожна людина хоче, щоб цього стресу було якомога менше, і намагається свідомо уникати стресових ситуацій.

Почавши писати на 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 дізнатися думки інших людей, так як після «офіційного» виходу, змінювати щось кардинально стане складніше.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.