Pull to refresh

Fastlane для Android разработчиков

Level of difficultyMedium
Reading time9 min
Views1.5K

Рост команды требует большего вовлечения в процессы и договорённости, которые, в свою очередь, требуют автоматизации и инспекции. Можно взять bash-скрипты и закрыть ими эту потребность, но насколько это будет удобно? Тут нужен инструмент, который упростит разработку и будет поддерживать команду в будущем. Сегодня расскажу про один из таких инструментов — Fastlane — и его возможности.

Статья будет полезна для ознакомления с Fastlane, тем, кто ищет решения для разработки автоматизации или рассматривает альтернативные решения по автоматизации сборок и процессов внутри компании. Для наглядности все примеры запускаются локально, это же решение можно перенести на CI/CD (Gitlab, Jenkins, Github Actions и тп).

Не хочу читать, дай мне код: https://github.com/maluginp/fastlanetestproject

Подготовка проекта под Fastlane

Всегда думал, что Fastlane больше подходит для iOS-разработчиков. Ох, как же сильно я заблуждался. На самом деле это огромный набор готовых инструментов для автоматизации всевозможных “хотелок”: прогон unit-тестов, линтеров, публикаций и т.п. У Fastlane большое комьюнити, которое вносит перманентный вклад в развитие продукта. Советую посмотреть список доступных методов (action-ов) под разные платформы тут. Если объединиться с iOS командой, то можно еще и код между собой шарить.

Теперь погнали настраивать среду для Fastlane. Для удобства подготовил тестовый проект под статью.

К счастью, по настройке Fastlane комьюнити подготовило полноценную инструкцию

Fastlane настроен и добавлен в проект. После инициализации Fastlane сгенерировал команды (lane-ы) под Android:

default_platform(:android)

platform :android do
  desc "Runs all the tests"
  lane :test do
    gradle(task: "test")
  end

  desc "Submit a new Beta Build to Crashlytics Beta"
  lane :beta do
    gradle(task: "clean assembleRelease")
    crashlytics
  
    # sh "your_script.sh"
    # You can also use other beta testing services here
  end

  desc "Deploy a new version to the Google Play"
  lane :deploy do
    gradle(task: "clean assembleRelease")
    upload_to_play_store
  end
end

Изначально получаем три простые команды, которые закрывают базовые потребности для автоматизации публикации Android приложения: прогон юнит-тестов, отрпавка беты в Crashlytics и Play store.

Как организовать код 

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

Не являюсь спецом в Ruby, поэтому пишу, как умею))

Каждый модуль — это одна фитча или несколько микро-фитч (если можно логически объединить), например, линтеры:

require 'fastlane'

# Linter methods
module Linter

  def self.detekt(throw_if_fails: true)
    # ./gradlew detekt
  end

  def self.lint(throw_if_fails: true)
    # ./gradlew lint
  end
end

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

Кейсы

Прогон линтеров, чтобы не упало качество кода

Для этого нам нужно:

  • Прогнать detekt

  • Прогнать lint

  • Если что-нибудь не прошло, возвращаем ошибку

Для оптимизации предлагаю прогонять detekt и lint независимо, в конце проверить результат и если есть ошибки, то возвращаем ошибку lane.

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

require 'fastlane'

# Linter methods
module Linter

  def self.detekt(throw_if_fails: true)
    Fastlane::Actions::GradleAction.run(
      task: 'detekt',
      project_dir: '../',
      # Без print_* аргументов, результат 
      # не будет выводится в консоли
      print_command: true,
      print_command_output: true
    )
    true
  # При ошибке выполнение команды Fastlane кидает
  # исключение, которое можно отловить
  rescue FastlaneCore::Interface::FastlaneShellError
    # Кидает исключение на верх (вызывающим объектов)
    # если не собираемся обрабаывать по своему
    raise if throw_if_fails

    false
  end

  def self.lint(throw_if_fails: true)
    Fastlane::Actions::GradleAction.run(
      task: 'lint',
      project_dir: '../',
      print_command: true,
      print_command_output: true
    )
    true
  rescue FastlaneCore::Interface::FastlaneShellError
    raise if throw_if_fails

    false
  end
end

Добавим новый lane в Fastfile для вызова команды:

desc 'Check linter rules'
lane :lint do
  lint_res = Linter.lint(throw_if_fails: false)
  # Пишем в логах ошибку
  Fastlane::UI.error('Lint failed') unless lint_res

  detekt_res = Linter.detekt(throw_if_fails: false)
	# Пишем в логах ошибку
  Fastlane::UI.error('Detekt failed') unless detekt_res

  unless detekt_res && lint_res
    Fastlane::UI.user_error!("Lint failed. Result detekt = #{detekt_res}, lint = #{lint_res}")
  end
end

Для запуска в терминале набираем fastlane lint, в тестовом проекте получаю, что lane закончен неуспешно и логи:

Lint failed. Result detekt = false, lint = true

Все работает, перейдем к следующему кейсу.

Safe-merge, прогон unit-тестов

Бывает такое, что в текущей ветке все тесты и линтеры проходят, но при мерже в главную ветку линтер или тесты ломаются (для простоты считаем, что все merge request-ы идут в главную ветку). Можно просить разработчиков подливать себе свежую главную ветку, но мы пойдем по пути автоматизации. Для этого реализуем safe merge (в ветку без merge-коммита подливаем последние коммиты из главной ветки), прогоняем тесты и линтеры.

Напишем модуль для safe-merge:

require 'fastlane'

# Safe merge for actual branch
module SafeMerge

  def self.main_branch?
		# Название главной ветки будет брать из env-параметров
    ENV['MAIN_BRANCH'] == fetch_local_branch
  end

  def self.merge_main_no_commit
    main_branch = ENV['MAIN_BRANCH']

    local_git_branch = fetch_local_branch

    command = [
      'git',
      'merge',
      main_branch,
      '--no-commit',
      '--no-ff'
    ]

    Fastlane::Actions.sh(command.join(' '))
    # пишем в логах успешность мержа
    Fastlane::UI.success("Successfully merged #{main_branch} (main branch) to #{local_git_branch}")
  end

  def self.fetch_local_branch
    local_git_branch = Fastlane::Actions.git_branch_name_using_HEAD
    local_git_branch = Fastlane::Actions.git_branch unless local_git_branch && local_git_branch != 'HEAD'
    local_git_branch
  end
end

и модуль для unit-тестов по аналогии с detekt и lint:

require 'fastlane'

# Unit test methods
module UnitTests

  def self.run(throw_if_fails: true)
    Fastlane::Actions::GradleAction.run(
      task: 'test',
      project_dir: '../',
      print_command: true,
      print_command_output: true
    )
    true
  rescue FastlaneCore::Interface::FastlaneShellError
    raise if throw_if_fails

    false
  end
end

Осталось добавить lane для unit-тестов

desc 'Run unit tests'
lane :unitTest do
	# Выводим отладочное сообщение, что не запускаем safe-merge
  # так как текущая ветка и есть главная
  UI.message('Skip safe merge, the branch is main') if SafeMerge.main_branch?

  SafeMerge.merge_main_no_commit unless SafeMerge.main_branch?

  UnitTests.run
end

Для запуска в терминале набираем fastlane unitTest , если unit-тесты проходят успешно, то lane завершится успехом.

Пока все просто, перейдем к более сложным кейсам.

Сборка релизных версий, отправка нотификаций через Slack

Чтобы QA не собирать сборку вручную (уверен, что они умеют это делать) и не отвлекать разработчиков, запросили автоматизацию сборки и отправки в Slack.

Новый модуль для релизных сборок для QA:

require 'fastlane'

require_relative 'building_slack'

# QA build methods
module QABuild

  def self.run(debug: false)
    # Это простой класс хелпер для отправки Slack-сообщений
    thread = BuildingSlackThread.start(
			# Параметры будем брать из env-параметров
      ENV['SLACK_API_TOKEN'],
      ENV['SLACK_QA_CHANNEL_BUILDS'],
      'Building QA build...',
      debug
    )

    begin
      # Собираем релизный APK-файл
      Fastlane::Actions::GradleAction.run(
          task: 'assemble',
          flavor: '',
          build_type: 'Release',
          project_dir: "../",
          print_command: true,
          print_command_output: true
      )

			# Достаем путь к собранному APK
      completed_apk_path = Fastlane::Actions.lane_context[
        Fastlane::Actions::SharedValues::GRADLE_APK_OUTPUT_PATH
      ]

			# Прикрепляем APK-файл к Slack-треду
      thread.attach_file('APK file', completed_apk_path, 'app.apk')

			# Собираем релизный AAB-файл
      Fastlane::Actions::GradleAction.run(
          task: 'bundle',
          flavor: '',
          build_type: 'Release',
          project_dir: "../",
          print_command: true,
          print_command_output: true
      )

			# Достаем путь к собранному AAB
      completed_aab_path = Fastlane::Actions.lane_context[
        Fastlane::Actions::SharedValues::GRADLE_AAB_OUTPUT_PATH
      ]

      # Прикрепляем AAB-файл к Slack-треду
      thread.attach_file('Bundle file', completed_aab_path, 'app.aab')

      # Обновляем главный тред - сборка выполнена успешно,
			# тут можно накидать смайлов
      thread.success('Building QA build succeed')
    rescue
      # Обновляем главный тред - сборка провалилась,
			# тут можно накидать смайлов и прикрепить детали
      thread.failure('Building QA build failed')
      raise # кидает исключение на верх к вызывающему методу
    end
  end
end

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

Процесс сборки отображается в Slack
Процесс сборки отображается в Slack

Напишем lane для запуска команды для сборки версий для QA:

desc 'Submit release builds to QA via Slack'
lane :qa do
  QABuild.run(debug: true)
end

Для запуска в терминале набираем fastlane qa

Публикация приложений в Play Store с раскаткой 10%

Модуль очень похож на модуль по сборке релизных версий для QA, основное отличие в вызове action-а для публикации в Play Store - Fastlane::Actions::UploadToPlayStoreAction

require 'fastlane'

module Deploy
  def self.run(debug: false)
    thread = BuildingSlackThread.start(
      ENV['SLACK_API_TOKEN'],
      ENV['SLACK_RELEASE_CHANNEL_BUILDS'],
      'Releasing build...',
      debug
    )

    begin
      Fastlane::Actions::GradleAction.run(
          task: 'assemble',
          flavor: '',
          build_type: 'Release',
          project_dir: "../",
          print_command: true,
          print_command_output: true
      )

      completed_apk_path = Fastlane::Actions.lane_context[
        Fastlane::Actions::SharedValues::GRADLE_APK_OUTPUT_PATH
      ]

      thread.attach_file('APK file', completed_apk_path, 'app.apk')

      Fastlane::Actions::GradleAction.run(
          task: 'bundle',
          flavor: '',
          build_type: 'Release',
          project_dir: "../",
          print_command: true,
          print_command_output: true
      )

      completed_aab_path = Fastlane::Actions.lane_context[
        Fastlane::Actions::SharedValues::GRADLE_AAB_OUTPUT_PATH
      ]

      thread.attach_file('Bundle file', completed_aab_path, 'app.aab')

      unless debug
        Fastlane::Actions::UploadToPlayStoreAction.run(
          aab: completed_aab_path,
          track: 'production',
          rollout: 0.1 # 10%
        )
      end

      thread.success('Releasing is completed and rollout on 10%')
    rescue
      thread.failure('Releasing is failed')
      raise # re-raise
    end
  end
end

Напишем lane для деплоя

desc 'Deploy a new version to the Google Play'
lane :deploy do
  Deploy.run(debug: true)
end

Для запуска в терминале набираем fastlane deploy

Отправка процесса сборки через Slack

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

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

Для упрощения работы с Slack API я подключил плагин к Fastlane: fastlane-plugin-slack_bot

Для подключения плагина нужно добавить следующие строчки в Gemfile (находится в корне проекта)

plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile')
eval_gemfile(plugins_path) if File.exist?(plugins_path)

В папке fastlane создать новый файл Pluginfile с содержимым

gem 'fastlane-plugin-slack_bot'

У вас должна получиться следующая структура:

{root}
|-- Gemfile
|-- fastlane
    |-- Pluginfile 

Сам код класса выглядит следующим образом

BuildingSlackThread.rb
require 'fastlane'

# Manage slack thread for building
class BuildingSlackThread

	# приватные свойства
  attr_accessor :api_token, :thread_ts, :thread_channel, :debug

  def self.start(api_token, channel, message, debug = false)
    Fastlane::UI.message("Sending start building message to Slack channel #{channel}")

		# При дебаге не шлем сообщения, но логика проходит
    if debug
      return BuildingSlackThread.new(api_token, channel, message, debug: debug)
    end

		# Создаем новый тред в канале с которым и будем взаимодействовать
    thread_result = Fastlane::Actions::PostToSlackAction.run(
      api_token: @api_token,
      message: message,
      success: true,
      channel: @channel,
      payload: [],
      attachment_properties: {},
      default_payloads: %i[lane git_branch git_author last_git_commit],
    )

    thread_ts = thread_result[:json]["ts"]
    thread_channel = thread_result[:json]["channel"]

    Fastlane::UI.error("Failed sending message to Slack to #{channel} channel") unless thread_ts && thread_channel

    BuildingSlackThread(api_token, thread_ts, thread_channel, debug)
  end

  def initialize(api_token, thread_ts, thread_channel, debug: false)
    @api_token = api_token
    @thread_ts = thread_ts
    @thread_channel = thread_channel
    @debug = debug
  end

  def attach_file(message, file_path, file_name)
    ext = file_name.split('.').last

    Fastlane::UI.message("Attaching #{file_name} (ext = #{ext}) file is located in path #{file_path}")

    return if @debug

    Fastlane::Actions::FileUploadToSlackAction.run(
      api_token: @api_token,
      initial_comment: message,
      file_path: file_path,
      file_name: file_name,
      file_type: ext,
      channels: @thread_channel,
      thread_ts: @thread_ts
    )
  end

  def success(message)
    Fastlane::UI.message("Sending success building message")

    return if @debug

    Fastlane::Actions::UpdateSlackMessageAction.run(
      ts: @thread_ts,
      api_token: @api_token,
      message: message,
      success: true,
      channel: @thread_channel,
      payload: [],
      attachment_properties: {},
      default_payloads: %i[lane git_branch git_author last_git_commit],
    )
  end

  def failure(message)
    Fastlane::UI.message("Sending failure building message")

    return if @debug

    Fastlane::Actions::UpdateSlackMessageAction.run(
      ts: @thread_ts,
      api_token: @api_token,
      message: message,
      success: false,
      channel: @thread_channel,
      payload: [],
      attachment_properties: {},
      default_payloads: %i[lane git_branch git_author last_git_commit],
    )
  end
end

Подытожим

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

Ключевые заметки:

  • Fastlane оказался удобен для разработки инструментов автоматизации

  • Fastlane подходит для Android и iOS приложений (для iOS функционала из коробки гораздо больше)

  • Все описанные кейсы можно перенести в CI/CD

  • Понять основы Ruby можно за день, если есть бэкграунд в разработке

  • С моей точки зрения, на Ruby c Fastlane разрабатывать автоматизацию гораздо проще, чем на том же Python.

Only registered users can participate in poll. Log in, please.
Что используете для автоматизации Android проектов?
28.57% Fastlane2
42.86% Bash-скрипты3
28.57% Kotlin Custom Scripts (kts)2
57.14% Gradle-скрипты4
0% Другое (поделитесь опытом в комментариях)0
7 users voted. 3 users abstained.
Tags:
Hubs:
Total votes 7: ↑7 and ↓0+7
Comments0

Articles

Information

Website
garage-eight.com
Registered
Founded
Employees
201–500 employees
Location
Россия