Олег Алексеенко, руководитель iOS разработки Superjob, рассказывает об опыте компании по переходу c Objective-C на Swift.

Статья написана по материалам выступления на RIT2017.



У Superjob 3 мобильных приложения для iOS:

  • Поиск работы (создание резюме, поиск вакансий и т.д.)
  • Поиск сотрудников (создание вакансий, поиск резюме)
  • Производственный календарь (планирование рабочего времени и отпусков)




В сутки приложения скачивают более 3 тысяч раз, количество активных пользователей в сутки — более 250 тысяч.

Первые версии приложений разработаны в 2012 году. Все приложения изначально были написаны на Objective-C. По прошествии 5 лет вышел новый язык Swift, мы решили перейти на него. Чего мы хотели добиться?

  1. Повысить предсказуемость кода. В приложении есть модель фильтра по 20 параметрам, и эта модель должна реализовать метод (быть сравниваемой). Много мест, где возможны изменения. Это все приносит много боли в бизнес-логику, так как при добавлении новых свойств все должно учитываться во всех разделах приложения. На Objective-C за этим нужно следить руками, проверять все 100500 мест — вероятность ошибки повышается. Swift делает такую ситуацию невозможной в принципе.

  2. Заблаговременно мигрировать с objc-библиотек. Новые библиотеки и UI-компоненты пишутся на Swift. Старые не поддерживаются. Для примера: ситуация с Reactive Cocoa. Если не перейти сегодня, то через пять лет у нас в приложении будет мертвый кусок.

  3. Повысить эффективность команды и стабильность развития проекта: мы стали более привлекательны для новых сотрудников, приняли внутренние стандарты качества работы. 70% новых кандидатов в команду разработки Superjob хотели писать именно на Swift.

Какие конкретно шаги в нашем случае пришлось сделать для перехода


Повышаем Nullability

На старте — менее 5%. Невозможность начать переход здесь и сейчас. При первой попытке внедрения Swift было много мест в приложении, где Objective-C говорил нам о существовании объекта, а по факту в run-time объекта не было. Когда мы пытались передать такой объект в Swift, приложение падало.


Как решали:

  • 3 месяца на каждый измененный файл добавляли Nullability.
  • Написали скрипт, который не дает пройти Pull Request без меток.

Чего добились: через три месяца Nullability достиг 60%. Это порог, с которого можно начинать переход.

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

Заблаговременно мигрируем с objc-библиотек

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

  • Те, которые легко заменили на Swift-аналог.
  • Те, которые просто не менялись, а требовали написания миграции или подбора аналога (с последующим написанием миграции) на этот долбаный аналог.

В нашем случае в проекте была библиотека ReactiveCocoa.

  • Нет понимания, что получаешь. К примеру, вот так выглядит вызов ReactiveCocoa-метода в Swift
    profileFacade.authorize(withLogin: login, password: pass).doNext(block: ((Any?) -> Void)!)
    И проблема как раз в
    (Any?)
    так как нет понятия, что сюда может прийти.

  • И поэтому необходимо каждый раз кастить к нужному типу и постоянно помнить, к чему кастим

    profileFacade
    .authorize(withLogin: login, password: pass)
    .subscribeNext { (response) in
        if let user = response as? SJAProfileModel {
           print("\(String(describing: user.name))")
         }
    }


Мы определи критерии решения:

• Чтобы было легко пользоваться.
• Чтобы была строгая типизация.
• Swift like API.

В итоге выбрали RxSwift.

Как мы подружили ReactiveCocoa с RxSwfit


В качестве решения мы написали категорию на RACSignal, которая превращает нетипизированные сигналы ReactiveCocoa в типизированные Observable RxSwfit. Первые шаги: создаем Observable и подписываемся на новые значения RACSignal. При получении новых данных в RACSignal пытаемся привести их к типу, которые мы указали в generic, с помощью convertBlock (его рассмотрим чуть позже). Если получается, то пересылаем новое типизированное значение дальше подписчикам Observable. А если нет, то сообщаем об ошибке приведения к нужному типу.

extension RACSignal {
 private func rxMapBody<T>(convertBlock: @escaping (Any?) -> T?) -> Observable<T> {
            return Observable.create() { observer in
                  self.subscribeNext(
                      { anyValue in
                             if let converted = convertBlock(anyValue) {
                                 observer.onNext(converted)
                             } else {
                                  observer.onError(RxCastError.cannotConvertTypes)
                             }
                   },
                  ...
                  ...
                  ...
                   return Disposables.create() {
                   }
          }
  }

Дальше у нас есть уже публичный метод, который внутри себя вызывает функцию создания Observable и передает в closure значения от RACSignal, которые приводятся к необходимому типу, указанному в generic.
extension RACSignal {
     public func rxMap<T>(_ type: T.Type = T.self) -> Observable<T> {
          return rxMapBody() { anyValue in
             if let value: T = rx_cast(anyValue) {
                 return value
             } else {
                return nil
             }
         }
    }
}

Такое решение хорошо подходит для стандартных типов и коллекций, например NSArray легко кастится в swift Array, NSNumber — в swift, но в ReactiveCocoa есть такая структура данных, как RACTuple. С ней возникла проблема, так как просто скастить ее в сarthage не получается, поэтому специально для RACTuple пришлось писать отдельный метод, который распаковывает каждое значение из RACTuple и собирает из них carthage.

public func rxMapTuple<Q, W>(_ type: (Q, W).Type = (Q, W).self) -> Observable<(Q, W)> {
       return rxMapBody() { anyValue in
           if let convertible = anyValue as? RACTuple,
               let value: (Q, W) = convertible.rx_convertTuple()
           {
               return value
           } else {
               return nil
           }
        }
   }

И как само ядро — сделана функция приведения нетипизированного значения к типизированному.

internal func rx_cast<T>(_ value: Any?) -> T? {
    if let v = value as? T {
        return v
    } else if let E = T.self as? ExpressibleByNilLiteral.Type {
        return E.init(nilLiteral: ()) as? T
    }
    return nil
}

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

Теперь можно вызвать функцию rxMap у RACSignal и передать необходимый тип, который мы ожидаем в блоке subscribe, и с этого момента на onNext мы всегда будем получается модель пользователя

profileFacade
                         .authorize(withLogin: login, password: pass)
                         .rxMap(SJAProfileModel.self)
                         .subscribe(onNext: { (user) in
                         })
                         .addDisposableTo(disposeBag)

Нужно сделать еще удобнее и написать расширения на сам фасад


extension SJAProfileFacade {
    func authorize(login: String, passwrod: String) -> Observable<SJAProfileModel> {
        return self.authorize(withLogin: login, password: passwrod).rxMap()
    }
}

Мы в нем сразу показываем, что возвращаем Observable, а внутри просто вызываем rxMap(), и в данном случае не нужно указывать, к какому типу нужно привести. Тип сам подтягивается из возвращаемого значения.
В итоге мы избавляемся от необходимости каждый раз приводить типы, а делаем это только 1 раз


             profileFacade
                         .authorize(login: login, password: pass)
                         .subscribe(onNext: { (user) in
                         })
                         .addDisposableTo(disposeBag)

Objective так просто не отпускает

Большой объем кода действующего приложения нельзя заменить сразу. Это приводит к проблеме: не все возможности Swift доступны в Objective-C.

Что именно недоступно из того, что нам надо использовать:

  • Struct
  • Enum
  • Моки

Решение — Sourcery.

Это решение умеет автогенерировать код.

Проще понять это на примере: у нас есть структура Resume, которая должна удовлетворять протоколам Hashable, Equatable. Но если их реализовывать самостоятельно, то всегда приходится помнить, что нельзя забыть учесть новое свойство. Можно доверить все это делать Sourcery. Для этого укажем, что наша struct Resume удовлетворяет двум протоколам AutoHashable и AutoEquatable.

struct Resume:
    AutoHashable,                    
    AutoEquatable {                       
                                              
    var key: Int?                                                 
    let name: String?                                         
                                                                 
    var firstName: String?                                       
    var lastName: String?                                        
    var middleName: String?       
    var birthDate: Date?             
}

Сами эти протоколы ничего такого не представляются из себя


protocol AuthoHashable {}
protocol AutoEqutable { }

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

Теперь можно запустить Sourcery. Получаем файл, в котором автоматически сгенерирована имплементация протоколов Hashable и Equatable для Resume. Если встроить sourcery в билд-фазу, то не придется переживать, что, добавляя новые свойства нашему резюме, мы забудем их учесть.


extension Resume: Hashable {
    internal var hashValue: Int {
        return combineHashes([key?.hashValue ?? 0,
                              name?.hashValue ?? 0,
                              firstName?.hashValue ?? 0,
                              lastName?.hashValue ?? 0,
                              middleName?.hashValue ?? 0,
                              birthDate?.hashValue ?? 0, 0]) 
}
}
                                 

extension Resume: Equatable {}
internal func == (lhs: Resume, rhs: Resume) -> Bool {
    guard compareOptionals(lhs: lhs.key,
                           rhs: rhs.key,
                           compare: ==) else { return false }
    guard compareOptionals(lhs: lhs.name,
                           rhs: rhs.name,
                           compare: ==) else { return false }
    guard compareOptionals(lhs: lhs.firstName,
                           rhs: rhs.firstName,
                           compare: ==) else { return false }
    ...
    ...
    return true
}

Шаблоны автогенерации Hashbale и Equtable идут «из коробки», но это не ограничивает нас, так как мы можем самостоятельно написать шаблон для наших нужд. Например, у нас есть такой enum.


enum Conf {
    case Apps
    case Backend
    case WebScale
    case Awesome
}

Мы хотим построить какую-то логику на количестве перечислений в enum, для этого можно написать шаблон и передать его в Sourcery.


{% for enum in types.enums %}
extension {{ enum.name }} {
  static var numberOfCases:Int = {{ enum.cases.count}}
}
{% endfor %}

В этом шаблоне мы сканируем все найденые типы. Если это enum, тогда создаем для него расширение, в котором объявляем статическую переменную с количеством.


extension Conf {
  static var numberOfCases:Int = 4
}

Поэтому мы воспользовались такой возможностью написания своих шаблонов, чтобы портировать struct в Objective-c. Такую хитрость нам пришлось применить, чтобы в тех местах, которые еще не переписаны на Swift, мы смогли работать с резюме. В результате из нашей структуры автоматически генерируем class ResumeObjc, который мы можем использовать в старом Objective-c коде.



На примере моков для тестов

При написании тестов в Objective-c мы часто использовали для моков swizzling. Но в Swift это невозможно, поэтому приходилось создавать какой-нибудь «FakeProtocolClass» и там реализовывать все необходимые методы, добавлять специально дополнительные переменные, которые показывают, вызывался метод или нет. На помощь снова может прийти Sourcery, который автоматически генерирует такие моки.



Прокачать команду

За последние полгода три из четырех кандидатов на собеседованиях в Superjob говорили о желании работать именно на Swift.

При переходе на Swift важно было учесть организационные моменты в команде, такие как code style и работа с ресурсами. Команда уже много лет работала на Objective-C, а насчет Swift у каждого было свое видение. Поэтому понадобился инструмент, который помог бы направить команду в нужное русло. Один из самых известных инструментов для codestyle на Swift — это SwiftLint.

SwiftLint позволяет вводить собственные правила. Это помогло нам прописать ошибки, свойственные именно нашей команде, и быстро от них избавиться. Например, мы написали правило, которое запрещает использование ReactiveCocoa в Swfit.



Также мы хотели унифицировать работу с графикой, так как проект пережил несколько редизайнов. В этом помог SwiftGen: при удалении иконки он сообщает, в каких местах она использовалась.
Поделиться с друзьями
-->

Комментарии (9)


  1. potorof
    17.07.2017 20:28

    А не проще было бы написать полностью новый код на Swifte, а потом просто на него перейти?


    1. alekoleg
      18.07.2017 12:04

      В теории проще, но на практике у нас получилось, что приложению уже около 5 лет, там много всякой бизнес логики, а за один мах переписать все долго и дорого


  1. aamonster
    18.07.2017 18:55
    +1

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


    1. alekoleg
      19.07.2017 11:10
      +1

      Если так глубже смотреть, то действительно любое приложение можно писать и на swift, и на objective-c. Но видя сколько сил вкладывает Apple в swift, начали переходить на него, плюсом получили больше мотивации внутри команды, наняли людей в команду.


  1. house2008
    19.07.2017 10:26

    После перехода не получили проблем с временем компиляции проекта? И вообще баговости xcode, в частности рандомной рекомпиляции проекта и отваливание подсветки синтаксиса? Размер приложения и время старта не сравнивали до и после перехода?


    1. alekoleg
      19.07.2017 11:22

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


      1. house2008
        19.07.2017 14:15

        Спасибо. К сожалению Xcode 9 beta не решает проблему со скоростью компиляции, хотя там и добавили «New build system», по моим расчетам она дает максимум 10% к скорости компиляции. Сижу надеюсь к осени улучшат.


        1. alekoleg
          19.07.2017 18:24

          Да, тоже надеюсь на это.


          1. ManWithBear
            20.07.2017 16:24

            Статья от ребят из Убера о том, как они боролись со скоростью компиляции и старта приложения. Может поможет.