Минимизируем человеческий фактор в Swift

72268c300da409dddf5a637ca5ca0be0.jpgДмитрий Токарев

iOS Developer Иностудио

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

Минимизируем человеческий фактор в Swift — ИностудиоМинимизируем человеческий фактор в Swift — Иностудио

Менеджеринг ресурсов в приложении

Под списком ресурсов мы понимаем локализацию, шрифты, цвета, картинки и иконки. Для более удобной интеграции мы используем SwiftGen. 

SwiftGen — инструмент для автоматической генерации кода для ресурсов проектов. Он позволяет сократить время на разработку и избежать ошибок из-за человеческого фактора. 

Следующий код писал каждый разработчик:

let image = UIImage(named: "imageName")

Для ключа значения мы используем строковые литералы. Запись в кавычках никак не проверяется компилятором или средой разработки. Поэтому здесь может возникнуть несколько ошибок.

Опечатка. Тут, скорее всего, вы обнаружите ошибку, так как будете тестировать свой код. Но когда увидите пустые картинки или не применявшийся font или локализацию, то поймете, что что-то не так. Вам придётся потратить время просто на то, чтобы выяснить, что ошибка в строковом ключе.

Забывчивость. Классическая ситуация, можно забыть обновить код после редактирования своих ресурсов.

Но используя SwiftGen, у вас будет подобный код:

let image = Assets.Icons.chechmark.image

Нейминг, порядок вложенности, уровни доступа — всё это легко настраивается. Добавили какую-то картинку или строку в локализации, нажали комбинацию «command + B» и вуаля — в перечислении сгенерировалось нужное свойство. Тем самым мы уходим от ошибок выше и делаем процесс разработки немного комфортнее.

Установка SwiftGen

Самый простой способ добавить SwiftGen в проект — это использование CocoaPods. То есть исполняемый файл лежит в самом проекте и доставляется путём установки библиотеки, что будет удобно для всех участников команды. Всё, что нужно сделать — прописать в podfile pod 'SwiftGen'. Затем необходимо добавить Build Phase, которая запустит утилиту перед или после компиляции — »$PODS_ROOT»/SwiftGen/bin/swiftgen.

Добавление Build Phase, которая запустит утилиту — ИностудиоДобавление Build Phase, которая запустит утилиту — Иностудио

Конфигурация SwiftGen

Для того чтобы генерировать код, нам понадобится шаблон. Пакет SwiftGen из коробки добавляет минимально необходимый набор шаблонов для генерации перечислений. При необходимости шаблон можно отредактировать под себя. 

Поддержка настройки через YAML-файл swiftgen.yml позволяет указать пути к исходным файлам, кастомным шаблонам и дополнительным параметрам. Пример настроенного файла, который мы используем в своём проекте:

xcassets:
  - inputs:
     - Reservation/Resources/Colors.xcassets
   outputs:
     templatePath: colors-swiftui.stencil
     params:
       forceProvidesNamespaces: true
       forceFileNameEnum: true
       enumName: Colors
     output: Reservation/Resources/Generated/Colors+Generated.swift
  - inputs:
     - Reservation/Resources/Assets.xcassets
   outputs:
     templatePath: xcassets-swiftui.stencil
     params:
       forceProvidesNamespaces: true
       forceFileNameEnum: true
       enumName: Assets
     output: Reservation/Resources/Generated/XCAssets+Generated.swift

fonts:
  inputs:
    - Reservation/Resources/Fonts
  outputs:
    templatePath: fonts-swiftui.stencil
    output: Reservation/Resources/Generated/Fonts+Generated.swift

strings:
  inputs:
    - Reservation/Resources/Localizable
  outputs:
    templateName: structured-swift5
    params:
      enumName: Localization
    output: Reservation/Resources/Generated/Strings+Generated.swift

Примеры полученных файлов для цветов:

// swiftlint:disable identifier_name line_length nesting type_body_length type_name
internal enum Colors {
 internal static let error50 = ColorAsset(name: "error50")
 internal static let error500 = ColorAsset(name: "error500")
 internal static let neutral100 = ColorAsset(name: "neutral100")
 internal static let neutral150 = ColorAsset(name: "neutral150")
 internal static let neutral200 = ColorAsset(name: "neutral200")
 internal static let neutral300 = ColorAsset(name: "neutral300")
 internal static let neutral400 = ColorAsset(name: "neutral400")
 internal static let neutral500 = ColorAsset(name: "neutral500")
 internal static let onBackground500 = ColorAsset(name: "onBackground500")
 internal static let onSurface500 = ColorAsset(name: "onSurface500")
 internal static let primary400 = ColorAsset(name: "primary400")
 internal static let primary50 = ColorAsset(name: "primary50")
 internal static let primary500 = ColorAsset(name: "primary500")
 internal static let secondary100 = ColorAsset(name: "secondary100")
 internal static let secondary500 = ColorAsset(name: "secondary500")
 internal static let secondaryVariant50 = ColorAsset(name: "secondaryVariant50")
 internal static let secondaryVariant500 = ColorAsset(name: "secondaryVariant500")
}
// swiftlint:enable identifier_name line_length nesting type_body_length type_name

// MARK: - Implementation Details

internal struct ColorAsset {
 fileprivate let name: String
 
  internal var color: Color {
  Color(self)
 }
}

internal extension Color {
 /// Creates a named color.
 /// - Parameter asset: the color resource to lookup.
 init(_ asset: ColorAsset) {
  let bundle = Bundle(for: BundleToken.self)
  self.init(asset.name, bundle: bundle)
 }
}

private final class BundleToken {}

Примеры полученных файлов для fonts:

// swiftlint:disable all
// Generated using SwiftGen — https://github.com/SwiftGen/SwiftGen

#if os(macOS)
 import AppKit.NSFont
#elseif os(iOS) || os(tvOS) || os(watchOS)
 import UIKit.UIFont
 import SwiftUI
#endif

// swiftlint:disable superfluous_disable_command
// swiftlint:disable file_length

// MARK: - Fonts

// swiftlint:disable identifier_name line_length type_body_length
internal enum FontFamily {
 internal enum Montserrat {
  internal static let black = FontConvertible(name: "Montserrat-Black", family: "Montserrat", path: "Montserrat-Black.ttf")
  internal static let blackItalic = FontConvertible(name: "Montserrat-BlackItalic", family: "Montserrat", path: "Montserrat-BlackItalic.ttf")
  internal static let bold = FontConvertible(name: "Montserrat-Bold", family: "Montserrat", path: "Montserrat-Bold.ttf")
  internal static let boldItalic = FontConvertible(name: "Montserrat-BoldItalic", family: "Montserrat", path: "Montserrat-BoldItalic.ttf")
  internal static let extraBold = FontConvertible(name: "Montserrat-ExtraBold", family: "Montserrat", path: "Montserrat-ExtraBold.ttf")
  internal static let extraBoldItalic = FontConvertible(name: "Montserrat-ExtraBoldItalic", family: "Montserrat", path: "Montserrat-ExtraBoldItalic.ttf")
  internal static let extraLight = FontConvertible(name: "Montserrat-ExtraLight", family: "Montserrat", path: "Montserrat-ExtraLight.ttf")
  internal static let extraLightItalic = FontConvertible(name: "Montserrat-ExtraLightItalic", family: "Montserrat", path: "Montserrat-ExtraLightItalic.ttf")
  internal static let italic = FontConvertible(name: "Montserrat-Italic", family: "Montserrat", path: "Montserrat-Italic.ttf")
  internal static let light = FontConvertible(name: "Montserrat-Light", family: "Montserrat", path: "Montserrat-Light.ttf")
  internal static let lightItalic = FontConvertible(name: "Montserrat-LightItalic", family: "Montserrat", path: "Montserrat-LightItalic.ttf")
  internal static let medium = FontConvertible(name: "Montserrat-Medium", family: "Montserrat", path: "Montserrat-Medium.ttf")
  internal static let mediumItalic = FontConvertible(name: "Montserrat-MediumItalic", family: "Montserrat", path: "Montserrat-MediumItalic.ttf")
  internal static let regular = FontConvertible(name: "Montserrat-Regular", family: "Montserrat", path: "Montserrat-Regular.ttf")
  internal static let semiBold = FontConvertible(name: "Montserrat-SemiBold", family: "Montserrat", path: "Montserrat-SemiBold.ttf")
  internal static let semiBoldItalic = FontConvertible(name: "Montserrat-SemiBoldItalic", family: "Montserrat", path: "Montserrat-SemiBoldItalic.ttf")
  internal static let thin = FontConvertible(name: "Montserrat-Thin", family: "Montserrat", path: "Montserrat-Thin.ttf")
  internal static let thinItalic = FontConvertible(name: "Montserrat-ThinItalic", family: "Montserrat", path: "Montserrat-ThinItalic.ttf")
  internal static let all: [FontConvertible] = [black, blackItalic, bold, boldItalic, extraBold, extraBoldItalic, extraLight, extraLightItalic, italic, light, lightItalic, medium, mediumItalic, regular, semiBold, semiBoldItalic, thin, thinItalic]
 }
 internal enum OpenSans {
  internal static let bold = FontConvertible(name: "OpenSans-Bold", family: "Open Sans", path: "OpenSans-Bold.ttf")
  internal static let boldItalic = FontConvertible(name: "OpenSans-BoldItalic", family: "Open Sans", path: "OpenSans-BoldItalic.ttf")
  internal static let extraBold = FontConvertible(name: "OpenSans-ExtraBold", family: "Open Sans", path: "OpenSans-ExtraBold.ttf")
  internal static let extraBoldItalic = FontConvertible(name: "OpenSans-ExtraBoldItalic", family: "Open Sans", path: "OpenSans-ExtraBoldItalic.ttf")

Примеры полученных файлов для строк:

internal enum Localization {
 internal enum Authorization {
  internal enum Authorization {
   /// Бронирование офисных мест
   internal static let bookingOfficePlaces = Localization.tr("Authorization", "Authorization.bookingOfficePlaces")
   /// Cервис INOSTUDIO, позволяющий
   /// забронировать рабочее место в офисе.
   internal static let bookingOfficePlacesDescription = Localization.tr("Authorization", "Authorization.bookingOfficePlacesDescription")
   /// Неверно указан логин или пароль
   internal static let credentialError = Localization.tr("Authorization", "Authorization.credentialError")
   /// Доменный логин
   internal static let domainLogin = Localization.tr("Authorization", "Authorization.domainLogin")
   /// Доменный пароль
   internal static let domainPassword = Localization.tr("Authorization", "Authorization.domainPassword")
   /// Ваш логин
   internal static let login = Localization.tr("Authorization", "Authorization.login")
   /// Продолжить
   internal static let next = Localization.tr("Authorization", "Authorization.next")
   /// Ваш пароль
   internal static let password = Localization.tr("Authorization", "Authorization.password")
  }
 }
 internal enum Common {
  /// Отмена
  internal static let cancel = Localization.tr("Common", "cancel")
  /// см
  internal static let cm = Localization.tr("Common", "cm")
  /// Произошла техническая ошибка.
  /// Попробуйте еще раз.
  internal static let commonErrorMessage = Localization.tr("Common", "commonErrorMessage")
  /// 404
  internal static let commonErrorTitle = Localization.tr("Common", "commonErrorTitle")
  /// Выйти
  internal static let exit = Localization.tr("Common", "exit")
  /// Проверьте подключение к Интернету
  /// и VPN или обратитесь
  /// к администратору
  internal static let networkErrorMessage = Localization.tr("Common", "networkErrorMessage")
  /// Связь с сервером прервана
  internal static let networkErrorTitle = Localization.tr("Common", "networkErrorTitle")
  /// Нет
  internal static let no = Localization.tr("Common", "no")
  /// Перезагрузить
  internal static let refresh = Localization.tr("Common", "refresh")
  /// Сохранить
  internal static let save = Localization.tr("Common", "save")
  /// Резерв
  internal static let tab1 = Localization.tr("Common", "tab1")
  /// Поиск
  internal static let tab2 = Localization.tr("Common", "tab2")
  /// Карта мест
  internal static let tab3 = Localization.tr("Common", "tab3")
  /// Профиль
  internal static let tab4 = Localization.tr("Common", "tab4")
 }

Примеры полученных файлов для иллюстраций:

internal enum Assets {
 internal enum Authorization {
  internal static let logotype = ImageAsset(name: "logotype")
 }
 internal enum Icons {
  internal static let arrow = ImageAsset(name: "arrow")
  internal static let back = ImageAsset(name: "back")
  internal static let checkmark = ImageAsset(name: "checkmark")
  internal static let errorIcon = ImageAsset(name: "errorIcon")
  internal static let eyeOff = ImageAsset(name: "eyeOff")
  internal static let eyeOn = ImageAsset(name: "eyeOn")
  internal static let filter = ImageAsset(name: "filter")
  internal static let placeMap = ImageAsset(name: "placeMap")
  internal static let placeMapActive = ImageAsset(name: "placeMapActive")
  internal static let profile = ImageAsset(name: "profile")
  internal static let profileActive = ImageAsset(name: "profileActive")
  internal static let reserve = ImageAsset(name: "reserve")
  internal static let reserveActive = ImageAsset(name: "reserveActive")
  internal static let search = ImageAsset(name: "search")
  internal static let searchActive = ImageAsset(name: "searchActive")
  internal static let searchGray = ImageAsset(name: "searchGray")
  internal static let trash = ImageAsset(name: "trash")
  internal static let unwrapIndicator = ImageAsset(name: "unwrapIndicator")
  internal static let unwrapIndicatorOpen = ImageAsset(name: "unwrapIndicatorOpen")
 }
 internal enum MyReservation {
  internal static let myReservation = ImageAsset(name: "myReservation")
 }
 internal enum Profile {
  internal static let blueCircle = ImageAsset(name: "blueCircle")
  internal static let edit = ImageAsset(name: "edit")
  internal static let exit = ImageAsset(name: "exit")
 }
 internal enum Search {
  internal static let cancelCross = ImageAsset(name: "cancelCross")
  internal static let searchHint = ImageAsset(name: "searchHint")
 }

Все использованные ресурсы со строковыми ключами теперь можно заменить на то, что у нас сгенерировалось. Это поможет упростить отслеживание удалений и изменений в парах «ключ — значение» для строк локализации. 

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

Промежуточный итог — SwiftGen

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

Единый code style на проекте

SwiftLint — это утилита для статического анализа Swift-кода, которая проверяет его на соответствие стилю, принятому в сообществе разработчиков. За основу взят Swift style guide от Github. 

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

Установка SwiftLint

SwiftLint — консольное приложение, которое устанавливается через Homebrew, поэтому для установки используется консольная команда brew install swiftlint. После чего необходимо добавить Build Phase, которая будет запускать SwiftLint.

Необходимо добавить Build Phase, которая будет запускать SwiftLint — ИностудиоНеобходимо добавить Build Phase, которая будет запускать SwiftLint — Иностудио

Настройка SwiftLint

Допустимо использовать дефолтные правила, которые идут вместе со SwiftLint, а можно добавлять кастомные. Мы на проектах Иностудио используем правила от raywenderlich.com. Единственное, изменяем правило двух табов на четыре.

indentation_width:
  indentation_width: 4

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

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

f1610dde718feca9fe1ca4f944df8c0f.png

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

5febcd36ee4583bab52f389cc0c1f4e9.png

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

Промежуточный итог — SwiftLint

SwiftLint позволяет команде разработчиков меньше тратить время на код-ревью и проще ориентироваться в проекте даже через несколько месяцев.

Заранее решаем мердж-конфликты в проекте

Вишенкой на торте инструментов автоматизации становится XcodeGen. Одной из самых распространённых и затратных по времени проблем, с которыми сталкиваются IOS- и MacOs-разработчики, являются мердж-конфликты в файле .xcodeproj. Зачастую такие конфликты появляются при изменении файловой структуры проекта (добавление, удаление или перемещение файлов) в сливаемых ветках.

В целом файл .xcodeproj является легковесной БД, которая имеет устаревшее представление (NeXTSTEP). Ручное редактирование возможно, но доставляет слишком много несоизмеримых трудозатрат.

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

Установка XcodeGen

Поскольку XcodeGen, как и SwiftLint, является консольной утилитой, для её установки мы используем Homebrew. Пишем brew install xcodegen.

Настройка XcodeGen

Для генерации файла проекта .xcodeproj нам необходим шаблон с конфигурацией. 

  1. Создаём файл шаблона в корневой директории проекта …/project.yaml.

  2. Добавляем в него минимальные первоначальные настройки: имя проекта, префикс пакета, версию Xcode, целевую платформу развёртывания.

  3. Расширяем шаблон необходимыми настройками: схемы сборки, конфигурации таргетов, скрипты и так далее.

  4. Добавляем необходимые команды терминала, которые выполняются по завершении генерации (в нашем примере установка зависимостей CocoaPods).

После создания шаблона не забываем добавить исключения в .gitignore.

## Build generated
build/
*.xcodeproj/
DeriveData/

Вот так выглядит шаблон конфигурации на проекте Reservation:

  • первоначальная настройка проекта;

  • postGenCommand — установка CocoaPods после генерации проекта;

  • добавление ID команды разработчиков;

  • конфигурация схем сборки.

name: Reservation

## options section ##

options:
 bundleIdPrefix: com.inostudio
 xcodeVersion: '13.0.1'
 deploymentTarget: '15.0'
 groupSortPosition: top
 generateEmptyDirectories: true
 minimumXcodeGenVersion: '2.18.0'
 defaultConfig: App Release
 groupSortPosition: top
 developmentLanguage: ru
 postGenCommand: pod install

## settings section ##

settings:
 DEVELOPMENT_TEAM: 2375DJV45D

## configs section ##

configs:
 Dev Debug: debug
 Stg Debug: debug
 App Debug: debug
 Dev Release: release
 Stg Release: release
 App Release: release

Конфигурация таргета (в нашем случае для удобства переключения между серверами API мы использовали схемы конфигурации Dev, Stg, App):

## targetTemplates section ##

targets:
 Reservation:
  type: application
  platform: iOS
  deploymentTarget: 15.0
  scheme:
   configVariants:
     - Dev
     - Stg
     - App
  settings:
   base:
    MARKETING_VERSION: 1.0
    CURRENT_PROJECT_VERSION: 3
    DEVELOPMENT_TEAM: 2375DJV45D
    TARGETED_DEVICE_FAMILY: 1
    GENERATE_INFOPLIST_FILE: YES
    INFOPLIST_FILE: Reservation/Resources/Info.plist
    INFOPLIST_KEY_UIApplicationSceneManifest_Generation: YES
    INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents: YES
    INFOPLIST_KEY_UILaunchScreen_Generation: YES
    INFOPLIST_KEY_UILaunchStoryboardName: LaunchScreen
    INFOPLIST_KEY_UISupportedInterfaceOrientations: UIInterfaceOrientationPortrait
    INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone: UIInterfaceOrientationPortrait
    INFOPLIST_KEY_UIUserInterfaceStyle: Light
   configs:
    Dev Debug:
     PRODUCT_BUNDLE_IDENTIFIER: com.inostudio.ReservationDev
     INFOPLIST_KEY_CFBundleDisplayName: ReservationDev
    Dev Release:
     PRODUCT_BUNDLE_IDENTIFIER: com.inostudio.ReservationDev
     INFOPLIST_KEY_CFBundleDisplayName: ReservationDev
    Stg Debug:
     PRODUCT_BUNDLE_IDENTIFIER: com.inostudio.ReservationStage
     INFOPLIST_KEY_CFBundleDisplayName: ReservationStg
    Stg Release:
     PRODUCT_BUNDLE_IDENTIFIER: com.inostudio.ReservationStage
     INFOPLIST_KEY_CFBundleDisplayName: ReservationStg
    App Debug:
     PRODUCT_BUNDLE_IDENTIFIER: com.inostudio.Reservation
     INFOPLIST_KEY_CFBundleDisplayName: Reservation
    App Release:
     PRODUCT_BUNDLE_IDENTIFIER: com.inostudio.Reservation
     INFOPLIST_KEY_CFBundleDisplayName: Reservation
  sources:
    - path: Reservation

Добавление скриптов посткомпиляции (конфигурации SwiftGen и SwiftLint):

postCompileScripts:
   - script: |
        if [[ -f "${PODS_ROOT}/SwiftGen/bin/swiftgen" ]]; then
          "${PODS_ROOT}/SwiftGen/bin/swiftgen"
        else
          echo "warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it."
        fi
    name: SwiftGen
    basedOnDependencyAnalysis: false
  
- script: |
        export PATH="$PATH:/opt/homebrew/bin"
        PATH=/opt/homebrew/bin:$PATH
        if [ -f ~/com.raywenderlich.swiftlint.yml ]; then
         if which swiftlint >/dev/null; then
         swiftlint --no-cache --config ~/com.raywenderlich.swiftlint.yml
         else
          echo "warning: SwiftLint not installed, download from https://github.com/realm/SwiftLint"
         fi
        fi
    name: SwiftLint
    basedOnDependencyAnalysis: false

Для запуска генерации файла проекта по настроенному шаблону, а также для установки CocoaPods достаточно запустить xcodeGen generate.

Итоги

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

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

© Habrahabr.ru