24/7 twenty-four seven

iOS/OS X application programing topics.

Automate solving two-factor authentication for Apple ID on CI systems

Apple enforcing 2FA for all accounts.

Starting February 2021, additional authentication will be required for all users to sign in to App Store Connect. This extra layer of security for your Apple ID helps ensure that you’re the only person who can access your account. You can enable two-step verification or two-factor authentication now in the Security section of your Apple ID account or in the Apple ID section of Settings on your device.

It prevents some Fastlane automated tasks such as binary uploads without 2FA.

The best solution is to use the App Store Connect API key instead of the username and password authentication.

lane :release do
  api_key = app_store_connect_api_key(
    key_id: "D383SF739",
    issuer_id: "6053b7fe-68a8-4acb-89be-165aa6465141",
    key_filepath: "./AuthKey_D383SF739.p8",
    duration: 1200, # optional
    in_house: false, # optional but may be required if using match/sigh
  )

  pilot(api_key: api_key)
end

See also:

docs.fastlane.tools

However, if you want to automate an operation that is not supported by the App Store Connect API, such as downloading dSYM, or if you are unable to obtain an App Store Connect API key for some reason, I will share a way to automate to prompt the two-factor authentication code.

Set up a 3rd party web service to receive SMS

Vonage SMS API and their Australian phone number is able to receive SMS from Apple. Not many services seem to be able to receive Apple's 2FA SMS. I have tried Twilio and Clickatell, but they cannot receive Apple's SMS.

Sign up for Vonage and go to "Buy Numbers" to get an Australian phone number.

f:id:KishikawaKatsumi:20210126163252p:plain:w600

Add the phone number as a trusted phone number for Apple ID two-factor authentication. You can set multiple phone numbers.

f:id:KishikawaKatsumi:20210126165346p:plain:w600

Update Fastlane session cookies periodically on CI

When using Fastlane with an Apple ID that has two-factor authentication enabled, you will be asked to enter the two-factor authentication number when App Store Connect requires login.

In an interactive environment, you can enter the code, but on a CI systems, you cannot enter it and you will be stuck there. To solve this problem, you can generate a session cookie for successful login in advance and reuse it.

You can generate a session cookie with the fastlane spaceauth command and save the session variable or session cookie files to the CI cache for reuse.

See also:

docs.fastlane.tools

The following code automates 2FA authentication and updating Fastlane login sessions. You can run this code periodically on CI.

The generated session cookies are saved in the ~/.fastlane directory, so the ~/.fastlane directory is saved in the cache of CI and restored in each job of CI.

require "net/http"
require "uri"
require "pty"
require "expect"
require "fastlane"
require "spaceship"
require_relative "./setup_credentials"

def fastlane_spaceauth(user, password, default_phone_number)
  ENV["FASTLANE_USER"] = user
  ENV["FASTLANE_PASSWORD"] = password

  # SMS will be sent to the number specified
  # by `SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER` environment variable.
  ENV["SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER"] = default_phone_number

  $expect_verbose = true

  # Run the `fastlane spaceauth` command using PTY to respond to 2FA input
  cmd = "fastlane spaceauth"
  PTY.spawn(cmd) do |i, o|
    o.sync = true

    # If there is a valid session cookie, do nothing
    i.expect(/Pass the following via the FASTLANE_SESSION environment variable:/, 10) do |match|
      if match
        o.puts "y"
        return
      end
    end

    # If the session is invalid and need to enter the 2FA code
    i.expect(/Please enter the 6 digit code you received at .+:/, 60) do |match|
      raise "UnknownError" unless match
      sleep 10

      now = Time.now.utc - 120
      date_start = now.strftime("%Y-%m-%dT%H:%M:%SZ")
      date_end = (now + 120 + 120).strftime("%Y-%m-%dT%H:%M:%SZ")

      api_key = ENV["VONAGE_API_KEY"]
      api_secret = ENV["VONAGE_API_SECRET"]

      # Retrieve SMS containing 2FA code from the API
      uri = URI.parse("https://api.nexmo.com/v2/reports/records?account_id=#{api_key}&product=SMS&direction=inbound&include_message=true&date_start=#{date_start}&date_end=#{date_end}")
      request = Net::HTTP::Get.new(uri)
      request.basic_auth(api_key, api_secret)

      options = {
        use_ssl: true,
      }

      response = Net::HTTP.start(uri.hostname, uri.port, options) do |http|
        http.request(request)
      end

      records = JSON.parse(response.body)["records"]
      if records.nil? || records.empty?
        raise "NotFoundError"
      end
      message_body = records[0]["message_body"]

      # Parse a 2FA code from the SMS body
      code = message_body[/\d{6}/]
      if code.nil? || code.empty?
        raise "NotFoundError"
      end

      # Enter the code
      o.puts code
    end

    i.expect(/Pass the following via the FASTLANE_SESSION environment variable:/, 10) do |match|
      raise "UnknownError" unless match
      o.puts "y"
    end

    begin
      while (i.eof? == false)
        puts i.gets
      end
    rescue Errno::EIO
    end
  end
end

Another solution: Forwarding SMS using an Android device

On Android, you can get SMS from the program. So it may be possible to achieve this more cheaply by Android device with SIM and setting up a program to forward SMS to Gmail, etc.

See the following example:

github.com

Apple IDの2ファクタ認証をCI環境で突破する

【注意】この記事で紹介しているSMS APIサービスのVonageは利用規約により認証にVonageの電話番号を利用することを禁止しているという記述があるので、末尾の別解として載せたAndroidデバイスを使ってSMSを転送する方法が良さそうです。

help.nexmo.com

   

2021年2月から、App Store Connectにログインする際にすべてのApple IDで2ファクタ認証が必須になります。

Starting February 2021, additional authentication will be required for all users to sign in to App Store Connect. This extra layer of security for your Apple ID helps ensure that you’re the only person who can access your account. You can enable two-step verification or two-factor authentication now for the Apple ID associated with your developer account. Visit the Security section of your Apple ID account or the Apple ID section of Settings on your device.

この変更により、CIでFastlane等を使用してApp Store Connectの操作(バイナリのアップロード、TestFlightや審査の提出、etc.)を自動化しているところに支障をきたします。

これまでは2ファクタ認証が必須だったのはAccount Holderなど強い権限を持つ一部のアカウントだけでしたので、Account Holderではないアカウントを別に作成して、CIではそのアカウントを使用することでこの問題を回避できていました。

あるいはFastlaneは少し前にApp Store Connect APIの利用に完全対応したので、App Store Connect APIのキーを設定して利用する方法もあります。

前者の2FAを無効にしたアカウントを使う方法は2月以降は使えなくなります。また、すでに新規に作成するApple IDは2FAが必須になっており無効にできなくなっています。

理想の解決方法はApp Store Connect APIを利用することですが、App Store Connect APIキーの発行にはAccount Holderの権限が必要なため、クライアントワークの場合などすぐに対応できないこともあると思います。

この記事では2ファクタ認証が有効なApple IDでもCI環境でFastlaneが利用できるようにする方法を紹介します。

SMS送受信API

要するにSMSを受信できるサービスを利用します。

特別なことはないオーソドックスなアプローチです。

ただ、意外とAppleの2FAのSMSが届くサービスがなかったのでこれならいけた、というノウハウの共有になります。

結論をいうとVonageのSMS APIで、オーストラリアの電話番号を使うとAppleのSMSが受信できました。

Vonageにサインアップして「Buy Numbers」から電話番号を取得します。

f:id:KishikawaKatsumi:20210126163252p:plain:w600

Apple IDの2ファクタ認証に使用する電話番号は複数設定できるので、ここで取得した電話番号を追加します。

f:id:KishikawaKatsumi:20210126165346p:plain:w600

余談ですが、Vonageではオーストラリア以外の国の電話番号も取得できます。

いくつかの国の番号を試して、たまたま受信できたのがオーストラリアの番号だったというだけなので他の国の番号でもよいかもしれません。

国によって番号の維持費が違うのでより安価な番号が使えるならそれがよいでしょう。

受信したSMSはReports APIまたはWebのGUIから取得できます。

Incoming Webhookもあるので受信したらどこかに転送する、というのでもいいかもしれません。

Fastlaneのセッションを定期的に更新する

2ファクタ認証が有効なApple IDでFastlaneを利用する場合、App Store Connectのログインが必要なタイミングで2ファクタ認証の番号の入力が求められます。

インタラクティブな環境では番号の入力ができますが、CI環境では番号の入力ができずそこで止まってしまうのでその問題の解決としてログイン成功時のセッションクッキーを事前に生成しておき、それを再利用する方法があります。

具体的にはfastlane spaceauthコマンドにてログインセッションを生成し、セッション変数、あるいは保存されたセッションクッキーのファイルをキャッシュ等に保存して再利用する、となります。

詳しくは下記の公式ドキュメントをご覧ください。

docs.fastlane.tools

spaceauthコマンドによって生成されたセッション情報は、最初の1回は2FAの認証が必要ですが、有効な間に定期的に更新できれば2回目以降は2FAの認証は不要です。

ただしCI環境のマシン構成やタイムゾーンなどが大きく変わった場合には別のマシンと認識されるため再度2FAの認証が求められます。

そこで必要になるのが先ほどのSMS APIによる2ファクタ認証の自動化です。

最初の1回と、なんらかの理由により再度2ファクタ認証が必要になったときにCIの処理が止まってしまわないように自動で2ファクタ認証の番号が入力できるようにしておきます。

下記のコードは実際にヤプリのCIで利用しているFastlaneのセッションを定期的に更新する処理のコードです。

この処理を1日1回CIで定期実行しています。

生成されたセッションクッキーは~/.fastlaneディレクトリに保存されるので、~/.fastlaneディレクトリをCIのキャッシュに保存して、CIの各Jobで復元します。

今のところ、これでCIのJobが2FAにより失敗することはなくなりました。

もしかしたら1日の間にCIマシンの割り当てが大きく変わる、などがあると困るのかもしれませんが今のところ起こっていません。

Vonage APIで確認すると数日に1回は2FAのSMSが届いているようなので、なんらかの要因によりセッションが無効と判断されることがそこそこあるようです。 (複数のCIとBotの環境で利用しているので一般の環境よりは複雑です。)

require "net/http"
require "uri"
require "pty"
require "expect"
require "fastlane"
require "spaceship"
require_relative "./setup_credentials"

def fastlane_spaceauth(user, password, default_phone_number)
  ENV["FASTLANE_USER"] = user
  ENV["FASTLANE_PASSWORD"] = password

  # SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER環境変数を設定すると、
  # 複数の電話番号を登録している場合にどの番号にSMSを送るかを指定できる
  ENV["SPACESHIP_2FA_SMS_DEFAULT_PHONE_NUMBER"] = default_phone_number

  $expect_verbose = true

  # 2FAの入力に応答するためにPTYを使って`fastlane spaceauth`コマンドを実行する
  cmd = "fastlane spaceauth"
  PTY.spawn(cmd) do |i, o|
    o.sync = true

    # 有効なセッションクッキーがある場合は何もしない
    i.expect(/Pass the following via the FASTLANE_SESSION environment variable:/, 10) do |match|
      if match
        o.puts "y"
        return
      end
    end

    # セッションが無効で2FAの入力が必要な場合
    i.expect(/Please enter the 6 digit code you received at .+:/, 60) do |match|
      raise "UnknownError" unless match
      sleep 10

      now = Time.now.utc - 120
      date_start = now.strftime("%Y-%m-%dT%H:%M:%SZ")
      date_end = (now + 120 + 120).strftime("%Y-%m-%dT%H:%M:%SZ")

      api_key = ENV["VONAGE_API_KEY"]
      api_secret = ENV["VONAGE_API_SECRET"]

      # SMS APIから2FAのSMSを取得する
      uri = URI.parse("https://api.nexmo.com/v2/reports/records?account_id=#{api_key}&product=SMS&direction=inbound&include_message=true&date_start=#{date_start}&date_end=#{date_end}")
      request = Net::HTTP::Get.new(uri)
      request.basic_auth(api_key, api_secret)

      options = {
        use_ssl: true,
      }

      response = Net::HTTP.start(uri.hostname, uri.port, options) do |http|
        http.request(request)
      end

      records = JSON.parse(response.body)["records"]
      if records.nil? || records.empty?
        raise "NotFoundError"
      end
      message_body = records[0]["message_body"]

      # SMSの本文から2FAのコードを取得する
      code = message_body[/\d{6}/]
      if code.nil? || code.empty?
        raise "NotFoundError"
      end

      # コードを入力する
      o.puts code
    end

    i.expect(/Pass the following via the FASTLANE_SESSION environment variable:/, 10) do |match|
      raise "UnknownError" unless match
      o.puts "y"
    end

    begin
      while (i.eof? == false)
        puts i.gets
      end
    rescue Errno::EIO
    end
  end
end

ここからは蛇足ですがVonage以外のSMSが受信できるサービスはTwilioClickatellを試しました。

いろいろ試行錯誤しましたが、結論としてはAppleの2FAのSMSを受け取ることはこの2つのサービスでは不可能でした。

電話番号にはLong NumberとShort code(5-6桁の番号)がありAppleの2FAはShort codeから送られるようですが、どうもそれが受信できない模様です。

TwilioはリクエストするとShort codeの受信が有効になるアップグレードがあり、それを有効にして試したものの受信できませんでした。

ということで、いろいろなSMSのサービスを試したところVonageの番号だとAppleの2FAのSMSが受信できた、という話でした。

(別解)Androidデバイスを用いてSMSを転送する

これはまったく試してない方法ですが、AndroidはプログラムからSMSが取得できる手段があるそうです。なので、適当に余っている検証機にSIMを挿して、SMSを適当にGmail等に転送するようなプログラムを設定すればもっと安価に実現できるのかもしれません。

Androidデバイスを用いた方法は下記のコードで実現できるようです。

github.com

CIのシークレット変数に1Password CLIを利用する

CIでいろいろなタスクを自動化していると、CIで必要とするAPIのトークンやアカウント情報など設定しているシークレット変数が増えてきます。

たいていの場合はCIサービスのシークレット変数を利用すればよいですが、サービスによっては一度設定したシークレット変数を見ることができなかったり(GitHub ActionsやCircle CIが該当)、トークンやアカウント情報の更新や追加があったときにCIの変数を更新していくのが大変だったり、シークレット変数のメンテナンスはそこそこ面倒な作業です。

性質上かなり強い権限が設定されているトークンだったりすることもあるので、誰がその値をメンテナンスできるか、という管理の問題もあります。

そこで1Passwordをアカウント情報の共有に使っている組織なら、1PasswordはCLIの操作が提供されているのでCIから1Passwordのアカウント情報を取得すると便利だったので方法を共有します。

しばらく運用してみてわかったことは、すべてを1Passwordから取得するようにするのはシークレット変数の設定に1Password CLIのセットアップが必要で、初見の人には何をしているか分かりにくいのでDocs as Codeの側面が弱くなるというデメリットがあるので、ほとんどのシークレット変数は普通にCIサービスの機能を使う方が良いと思います。

その上で、

  • 更新や追加がそこそこある
  • 更新や追加をソフトウェアエンジニア以外の人がやることがある
  • シークレット情報の権限管理を簡単にしたい
  • 2要素認証のワンタイムパスワードをCIで入力したい

という課題がある場合に必要に応じて利用すると良いと思います。

ヤプリではApple IDと、Googleアカウントの情報をCIから1Password CLIを用いて取得しています。

Apple IDはクライアントごとのアプリの申請に必要でクライアントの増加とともにアカウントも増えます。

その際のアカウントの管理はソフトウェアエンジニア以外の人によって行われます。アカウントが追加されたときはCIのシークレット変数にも更新が必要ですが、1Passwordから取得することでメンテナンスの手間が不要になります。

GoogleアカウントはGoogleのサービスでAPIが提供されていない操作を自動化するために、ヘッドレスChrome APIライブラリのPuppeteerでログインするときの2要素認証をクリアするためにワンタイムパスワードを1Passwordから取得しています。

1Password CLIをCIの環境にインストールする

Macホストで動かす場合はHomebrewでインストールできます。

brew install --cask 1password-cli

LinuxやDocker環境の場合は下記のようにバイナリをダウンロードしてPATHの通っている場所に展開します。

export ONE_PASSWORD_VERSION="v1.8.0"

curl -sS -o 1password.zip https://cache.agilebits.com/dist/1P/op/pkg/$ONE_PASSWORD_VERSION/op_linux_amd64_$ONE_PASSWORD_VERSION.zip \
    && unzip -o 1password.zip op -d /usr/bin \
    && rm 1password.zip

詳しくは公式ドキュメントを見てください。

1Password CLIのセットアップ

1Password CLIで保管庫にアクセスするには、まず1Passwordのアカウントでログインして返ってきたセッション変数を環境変数に設定します。

CIのステップでセットアップするためのBashスクリプト、Fastlane等で利用するためのRubyスクリプト、Slack Bot等で利用するためのNode.jsスクリプトのそれぞれのセットアップ方法を下記に示します。

OP_PASSWORDOP_SIGN_IN_ADDRESSOP_EMAIL_ADDRESSOP_SECRET_KEYはCIのシークレット変数に設定します。

人の入れ替わり等でOP_〜変数の更新が発生しないようにCI専用の1Passwordユーザーを作成するとよいです。そのアカウント情報も1Passwordで管理するとうまくいきます。

# Bashスクリプト
yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS" "$OP_EMAIL_ADDRESS" "$OP_SECRET_KEY"
eval $(yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS")

 

# Rubyスクリプト
%x(yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS" "$OP_EMAIL_ADDRESS" "$OP_SECRET_KEY")
ENV["OP_SESSION_#{ENV["OP_SIGN_IN_ADDRESS"]}"] = %x(yes "$OP_PASSWORD" | op signin --raw "$OP_SIGN_IN_ADDRESS").strip

 

// Node.js
const env = process.env;

await exec(
  `yes "${env.OP_PASSWORD}" | op signin ${env.OP_SIGN_IN_ADDRESS} ${env.OP_EMAIL_ADDRESS} ${env.OP_SECRET_KEY}`,
  {
    env: env,
  }
);
const session = await exec(`yes "${env.OP_PASSWORD}" | op signin --raw`, {
  env: env,
});

env[`OP_SESSION_${env.OP_SIGN_IN_ADDRESS}`] = session.stdout.trim();

詳しくは公式ドキュメントを見てください。

1Password CLIからアカウント情報を取得する

1Password CLIを用いてアカウント情報を取得する例を下記に示します。

キーに対応するパスワードを取得する

もっとも基本的なキーに対応する値を取得する例は次のようになります。

envman add --key FASTLANE_PASSWORD --value `op get item "Apple ID - $YAPPLI_APP_STORE_USERNAME" --fields password`

このコードはApple ID - $YAPPLI_APP_STORE_USERNAMEという名前に対応したパスワードを1Passwordから取得して、FASTLANE_PASSWORDというキーで環境変数に設定しています。

op get item "<名前>" --fields passwordで名前に対応するパスワードが取得できます。

名前だけで一意に決まらない場合は--vaultオプションで保管庫を指定できます。

名前の代わりに内部的なUUIDを用いて参照することもできます。

名前で参照すると何を取得しているかが読みやすく、UUIDで参照すると何を取得しているのかが分かりにくくなりますが変更に強くなります(名前をいつでも好きに変えられる)。

特定の保管庫に保存されているアカウント情報をすべて取得する

次に特定の保管庫に保存されているアカウント情報をすべて取得してループする例です。

これは先に書いたようなアカウント情報の増減に自動的に対応するような処理を書く際に便利です。

def get_accounts
  %x(yes "$OP_PASSWORD" | op signin "$OP_SIGN_IN_ADDRESS" "$OP_EMAIL_ADDRESS" "$OP_SECRET_KEY")
  ENV["OP_SESSION_#{ENV["OP_SIGN_IN_ADDRESS"]}"] = %x(yes "$OP_PASSWORD" | op signin --raw "$OP_SIGN_IN_ADDRESS").strip

  items = JSON.parse(%x(op list items --vault "アプリ公開関係"))
  accounts = {}
  items.each do |item|
    account = JSON.parse(%x(op get item #{item["uuid"]} --fields username,password))
    accounts[account["username"]] = account["password"]
  end
  accounts
end

上記のコードは、まずop list items --vault "アプリ公開関係"として"アプリ公開関係"という名前の保管庫に保存されている項目をすべて取得します。

ここで返ってくる情報は概要のみ(UUIDや名前など)で実際のアカウント情報は含まれていないので、さらに各項目に対してループの中で先ほどのop get itemコマンドを使ってユーザー名とパスワードを取得しています。

最終的に、ユーザー名とパスワードがキーと値のHashオブジェクトが作られています。

2要素認証のワンタイムパスワードを取得する

1Passwordには2要素認証のワンタイムパスワードも共有できます。

その値もCLIを用いて取得できるので、2要素認証が必要な処理をCIで利用できます。

const env = process.env;
  
...

const totp = await exec(`op get totp ${env.GOOGLE_USERNANE}`, {
  env: env,
});

...

op get totp <名前>で共有されているワンタイムパスワードを取得できます。

ワンタイムパスワード(TOTP)なので時間によって変化するのでCIのシークレット環境変数等に登録することはできませんが、1Passwordに共有されているものを取得するようにすることでCIでも2要素認証をクリアできます。

上記の処理を用いてPuppeteerを使って、APIが提供されていないGoogleアカウントの操作を自動化しています。

実際のコード例を下記に示します。

const env = process.env;
  
await exec(
  `yes "${env.OP_PASSWORD}" | op signin ${env.OP_SIGN_IN_ADDRESS} ${env.OP_EMAIL_ADDRESS} ${env.OP_SECRET_KEY}`,
  {
    env: env,
  }
);
const session = await exec(`yes "${env.OP_PASSWORD}" | op signin --raw`, {
  env: env,
});

env[`OP_SESSION_${env.OP_SIGN_IN_ADDRESS}`] = session.stdout.trim();

const browser = await puppeteer.launch({ headless: false });
const page = await browser.newPage();
await page.goto("https://marketingplatform.google.com/home?authuser=0");

await page.type("#identifierId", env.GOOGLE_USERNANE);
await Promise.all([
  page.waitForNavigation({ waitUntil: ["load", "networkidle2"] }),
  page.click("#identifierNext > div > button"),
]);

await page.waitForTimeout(1000);
await page.waitForSelector('input[type="password"]');
await page.type('input[type="password"]', env.GOOGLE_PASSWORD);
await Promise.all([
  page.waitForNavigation({ waitUntil: ["load", "networkidle2"] }),
  page.click("#passwordNext > div > button"),
]);

await page.waitForTimeout(1000);
await page.waitForSelector("#totpPin");

const totp = await exec(`op get totp ${env.GOOGLE_USERNANE}`, {
  env: env,
});

await page.type("#totpPin", totp.stdout.trim());
await Promise.all([
  page.waitForNavigation({ waitUntil: ["load", "networkidle2"] }),
  page.click("#totpNext > div > button"),
]);

...

iOSDC 2019で「ライブラリのインポートとリンクの仕組み完全解説」という話をします

f:id:KishikawaKatsumi:20190905065623p:plain

スケジュール

概要(リンクにまつわる問題)

インポート・リンクの仕組みがよくわかっていない状態だと、エラーと自分の加えた変更が結びつかないので、よくわからないエラーが無限に起こっていると感じます(同じエラーメッセージを引き起こす原因は複数あるため)。

しかし実際はそうではないので、可能性の高い順番で確認していけば問題を解決できます。

そのための基礎として、インポート・リンクの仕組みを理解が必要になります。

モジュールのインポート・リンクがどのように解決されるのか、リンクとはいったい何をしているのかを学ぶことで、システマチックに問題を切り分け、解決できるようになります。

この話を聞いて得られること

基礎的な能力

  • ライブラリの形式に関する知識
  • インポートとリンクの知識
  • インポートとリンクにまつわるトラブル解決の能力

応用

  • ライブラリ作成における配布形態の技術判断
  • パッケージマネージャの高度な利用

話さないこと

Swiftコンパイラが具体的にバイナリをリンクする実装についてやOSが実行ファイルを読み込む方法、といった極端に低レイヤーのことは話しません。

それはシステムプログラミングの範疇です。

あくまでライブラリの利用者(応用編で少しだけ作成者についても)としてアプリケーションプログラミングの範囲で、ライブラリを利用可能にする基礎的な手順を解説します。

具体的な内容

以下に現在のスライドを少し示します。

例えば、問題解決チャートの設問に「リンクのエラー」か「インポートのエラー」か書かれていますが、この区別がつかない・自信がない人はけっこういるのではないでしょうか?

この話を聞くと、「これはインポートで起こってるエラー」「これはリンクエラー」「問題の切り分けとして〇〇をする」ということがスムーズにできるようになります。

f:id:KishikawaKatsumi:20190905074522p:plain:w300 f:id:KishikawaKatsumi:20190905074038p:plain:w300

f:id:KishikawaKatsumi:20190905074122p:plain:w300 f:id:KishikawaKatsumi:20190905074137p:plain:w300

f:id:KishikawaKatsumi:20190905074153p:plain:w300 f:id:KishikawaKatsumi:20190905074209p:plain:w300

FolioのiOSチームで利用しているFastfileとBitriseワークフロー

FolioのiOSチームではさまざまなタスクをそこそこ高度に自動化していると思うので、(そのまま別のプロジェクトで使いまわせるほどポータブルではないけど)参考にしてもらえる部分はけっこうありそうと思うので公開リポジトリに置いてみました。

github.com

簡単に解説します。

Fastfile

lane :snapshot_test

Folioアプリのユニットテストはいわゆる一般的なロジックテストに加えてスクリーンショットを用いたスナップショットテストがあります。

GitHub - uber/ios-snapshot-test-case: Snapshot view unit tests for iOS

目的は修正によって意図しない影響が起こっていないことを検証するためと、現状の画面の一覧をGitHubで変更管理したいからです(これについては詳細を後述)。

(ボタンを追加したら関係ないはずのラベルのテキストが溢れた、とか。あるいはエラー系の画面など通常の開発で確認を忘れたがちな画面についても安心できるとか)

これはよほど再現が難しい一部の画面をのぞいて、現在はかなりの画面をカバーできているので、安心できる反面、UIの変更でテストを一緒に修正しなければならない手間もあります。

なので、意図的なUIの変更はあまり何も考えずに関係する画面のスクリーンショットを撮り直して、git add -Aしてコミットする、というワークフローにしています。

(そうするとPRで確認できるし、変更は全部Gitに残るわけだからちょっと間違えても問題ない)

(それでもテストケースが増えたことでそこそこ大変になりつつあるので改善の余地あり)

このタスクは、スナップショットテストを必要なデバイスぶん記録モードで実行してローカルのスクリーンショットを上書きする、というものです。

このFastfileに定義されているタスクでは珍しくCIではなく手元で実行するタスクです。

lane :add_release_tag

アプリがリリースされたらそのタイミングでタグを作成する、というタスクですが、ちょうど良いトリガーのタイミングがなくて今は利用されていません。

lane : update_dependencies

導入しているライブラリの更新があったら自動的にCarthage.resolvedかPodfile.lockを更新してPRを出します。

ライブラリに更新があったら次のようなPRが夜の間に自動的に作成されます。基本的にテストが通っていればサッと確認してマージするだけ、Breaking Changeがある場合は必要に応じてこのPRにコミットを追加するか別PRでマニュアル作業で対応します。

f:id:KishikawaKatsumi:20190619155451p:plain

目的はライブラリの更新を溜めずに小さい単位にまとめてコントロール可能にすることです。

そのためにまず自動化したことで更新に気づくことができます。

そのままマージするだけのこともけっこうあるので負担の軽減ができます。

そうでない場合は対処が必要ですが、放置していると同じPRが無限に作成され続けるので、気持ちが悪いからどこかで対応しようという圧力になります。

このタスクはCIで平日の深夜に毎日実行されています。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L323-L337

lane : update_license_list

LicensePlistを実行して、利用しているライブラリのライセンス情報を更新します。

このタスクもCIで平日の深夜に毎日実行され、自動的にPRを作成します。基本的にただマージするだけです。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L338-L348

lane : update_tools

アプリで使用する以外のツール(主にFastlaneかCocoaPods)を自動的にアップデートします。 CIで平日の深夜に毎日実行され、自動的にPRを作成します。ただマージするだけです。それなら直接masterにコミットしてもいいのですが、PRを経由したほうが変更がわかりやすいのでそうしています。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L349-L359

lane : sync_bitrise_yml

https://github.com/folio-sec/Fastfile/blob/master/Fastfile#L91-L104

Bitrise.ymlをリポジトリにダウンロードします。変更があったら自動的にPRを作成します。

BitriseはWebインターフェースがとてもよくできていますが、一般的なCIサービスと同様に設定ファイルベースでも動作します。

というか、設定ファイルベースで動かしてタスクはGitで管理できる方が良いのですが、Bitriseの設定ファイル(Bitrise.yml)はWebインターフェースに比べると格段に難しく、またBitrise.ymlを優先で使うためには追加のセットアップが必要なので、それをやるのはいろいろ面倒になるのでやめました。

代わりに発想を逆転させて、Webインターフェースで設定した内容をリポジトリに定期的にダウンロードするようにしました。そのためのタスクがこれです。

平日の深夜にCIで実行されるので、最大で1日の遅延がありますが、CIのワークフローは今や頻繁に変更するものでもないので、変更があったタイミングでそれがわかって、追跡可能であるという要件はこれで完全に満たせていて、かつ明らかにこちらの仕組みの方が簡単なのでこのようにしています。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L252-L262

lane : screenshots_preview_generator

https://github.com/folio-sec/Fastfile/blob/master/Fastfile#L119-L123

スナップショットテストの実行で得られたスクリーンショットをMarkdown形式に整形してGitHubで簡単に確認できるようにします。

lane : refresh_dsyms

https://github.com/folio-sec/Fastfile/blob/master/Fastfile#L125-L144

AppStore Connectからデバッグシンボルをダウンロードして、Firebase Crashlyticsにアップロードします。

平日深夜にCIで実行されます。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L220-L234

lane : image_assets_tests, lane: folio_tests, lane: redux_tests, lane: notification_service_tests

https://github.com/folio-sec/Fastfile/blob/master/Fastfile#L146-L181

ユニットテストを実行します。実行時間の関係で、いくつかのテストは関係のあるファイルに変更があったときだけ実行できるように、テストの種類の応じてタスクを分割しています。

毎回のPRやmasterにマージされたタイミングで実行されます。

lane : folio_nightly_tests

毎回のPRで実行されるテストはすべてではないので、念のため、平日深夜にできるだけ多くのテストを実行するためのタスクです。

lane : upload_build_cache, lane: download_build_cache, lane: renew_build_cache

https://github.com/folio-sec/Fastfile/blob/master/Fastfile#L278-L303

Bitriseのキャッシュはブランチごと、PRでキャッシュは更新されないという仕様があるので、ライブラリの構成を変更するPRの場合、そのPRにコミットを追加する場合は基本的にライブラリのフルビルドが動いてしまう問題がありました。

現在のプロジェクトではCarthageとCocoaPodsでライブラリを管理していて、CocoaPodsについてもこちらの記事で紹介したようにビルド済みライブラリとして取り扱っているので、独自のキャッシュの仕組みを作ったのがこのタスクです。

Carthageのビルド済みライブラリと、CocoaPodsのビルド済みライブラリをtar.gzにアーカイブして独立したGitHubリポジトリのReleasesにアップロードします。

利用はReleasesからダウンロードして展開するだけです。

キャッシュのキーにはCarthage.resolvedとPodfile.lockのハッシュ値を使っています。ライブラリ構成に変更があったらこの値が変わるので新しいキャッシュが保存されます。

この仕組みにすることで、ライブラリの再ビルドは最初の1回だけ行われて、あとはキャッシュを利用できるようになり、PRのテストにかかる時間が大幅に短縮されました。

副次的なメリットとして、lane: download_cacheのタスクを手元で実行すると、CIでビルド済みのライブラリをダウンロードして利用できるようになりました。

このおかげで手元でライブラリをインストール、ビルドすることはほぼ不要になり、ライブラリ構成に変更があるようなPRをレビューする場合でもブランチを変更してlane: download_cacheを実行するだけでよくなり、待ち時間が減ってとても快適になりました。

(ネットワーク速度によるけど、オフィスでやる分には圧倒的にビルドするよりもダウンロードする方が速い)

この仕組みについては別の記事で詳しく書きたいと思います。

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L44-L60

Bitrise.yml

workflow: test

https://github.com/folio-sec/Fastfile/blob/master/bitrise.yml#L14-L25

ユニットテスト(スナップショットテストを含む)を実行します。

PRとPRのマージ(=masterへのPush)に実行されます。

次のリリースのためのブランチとしてv2.16.0のようなブランチができることがあるのでvで始まるブランチ名もmasterと同様に扱います。

workflow: deliver, workflow: deliver-external, workflow: deliver-internal

リリースビルドもしくはAd-HocビルドをしてAppStore ConnectかFabric Betaにアップロードします。

_release/*_testflight/*_fabric-beta/*という特定のPrefixで始まるブランチからPRが出されると動作する仕組みです。

Bitriseはこのようにブランチのネーミングルールでトリガーするワークフローをマッピングすると便利、ということに気づくと飛躍的に便利度が上がります。

リリースの際にはビルド番号をインクリメントするという退屈だけど正確に行わなければ失敗するタスクがあるので、私たちのチームではChatBotによりそれを自動化しています。

ChatBotはバージョンをインクリメントしてPRを出すところまでを担当し、PRが出るとCIでリリースビルドが作成される、という風にタスクのチェーンが繋がります。

ChatBotはこちら。

github.com

iOS 13にしかないフレームワークを使用したアプリをiOS 12以下でも動くようにするには

SwiftUI、Combile、RealityKitなどiOS 13以上の環境にしか存在しないフレームワークを使用するアプリをiOS 12以下の環境で実行すると、その機能を実際に呼び出さないようにしていたとしても、起動時にダイナミックリンクに失敗してクラッシュしてしまいます。

dyld: Library not loaded: /System/Library/Frameworks/RealityKit.framework/RealityKit
  Referenced from: /Users/katsumi/Library/Developer/CoreSimulator/Devices/7D73BD02-5C30-4723-9023-4D19BCDAE1AA/data/Containers/Bundle/Application/A9E00179-1DDD-4051-9207-7CC6C9DC50AE/UseIOS13.app/UseIOS13
  Reason: image not found

iOS 12以下のサポートは残しつつ、最新のOSにアップデートした人に対しては部分的にRealityKitやその他の最新機能は提供したいというユースケースでは問題になります。

この問題を解決するにはWeak Linkという仕組みを利用します。

最近はAuto Linkが作用するので明示的にフレームワークのリンクを設定することはめったにありませんが、Weak Linkを設定する場合は、明示的なリンクを利用します。

Xcodeのターゲットの設定から、Build Phases > Link Binary With Librariesを表示して、「+」ボタンから該当のフレームワークを選択して追加します。そしてStatusのカラムをOptionalに変更します。

f:id:KishikawaKatsumi:20190618151337g:plain

Combine.frameworkやRealityKit.frameworkは「+」ボタンからのダイアログの中には見つからないので/Applications/Xcode-beta.app/Contents/Developer/Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS.sdk/System/Library/Frameworks/を直接Finderで表示してドラッグ&ドロップで設定します。

f:id:KishikawaKatsumi:20190618152111p:plain

当然ながら、iOS 12以下ではその機能を呼び出さないようにコードで分岐したりそもそも画面を表示しないなど無いフレームワークの機能を使用しないように実行時にガードが必要です。

if #available(iOS 13.0, *) {
    ...
} else {
    ...
}

参考:

blog.kishikawakatsumi.com

CocoaPodsをWorkspaceに自動統合せずに利用する

背景

現在のiOSアプリ開発におけるパッケージマネージャのデファクトスタンダード(事実上の標準)としてCocoaPodsとCarthageがあります。Xcode 11からはSwift Pacakge ManagerがXcodeに統合されて利用できますが、ライブラリ側の対応が必要ということや、ベンダーライブラリなどを考えるとCocoaPodsは少なくとも当面は使われ続けるでしょう。

Carthageと違って、CocoaPodsは.xcodeprojに依存せず独自のビルドシステムを持つことや、(デフォルトでは)Workspaceにライブラリのプロジェクトを自動的に統合するので、リンクに関する設定をやらなくて済むという特徴があります。

しかし、これは長所でもあり短所でもあります。

リンクの設定を自動で追加するために、プロジェクトのビルド設定がCocoaPodsが自動で追加する記述によって非常に複雑になり、ライブラリの追加削除による.xcodeprojファイルのコンフリクトの解決は非常に大変です。

また、ワークスペースにCocoaPodsが管理するプロジェクトとして導入されるので、クリーンビルドの際はライブラリがすべて再ビルドされるため、非常に時間がかかってしまう問題があります。

(そしてiOSアプリの開発では謎のエラーによってクリーンビルドをしなければならないことがそこそこの頻度で発生します。)

Carthageではビルド済みライブラリとして導入されるので、クリーンビルドの問題はおこりませんが、Firebaseなど公式にはCarthageの導入をサポートしていないライブラリもあります(実験的にCarhtageインストールも用意されています)。

この問題は、CocoaPodsをWorkspaceに自動統合しない設定で利用することにより解決できます。

デメリットは、Workspaceが自動統合されないため、導入したライブラリをリンクする設定は自分で書く必要があることです。

CocoaPodsが自動的に行っていたライブラリのリンク設定を1つずつ記述していくのはそれなりに大変ですが、CocoaPodsの自動設定は前述のように.xcodeprojファイルを複雑にしてコンフリクトの解決を不可能にするという問題もあります。

この問題はリンク設定をマニュアルで行う場合は.xcodeprojファイルではなく.xcconfigに記述できるので、.xcodeprojファイルをシンプルに保つことができます。

また、Firebaseのように多くの依存関係を持つStatic FrameworkをCocoaPodsで導入する場合、複数のターゲットが存在する場合は、重複してリンクしてしまう問題を簡単には避けられません。

マニュアルですべてのライブラリをリンクしていくなら、確実に必要なターゲットを選んでリンクできるのでこの問題も解決できます。

(リンクに関する知識がそれなりに必要ですが、複数のターゲットにライブラリを導入するくらい複雑になってくると、どのみちリンクの知識は必要なので、CocoaPodsのバッドノウハウと格闘するよりは素直にリンクについて勉強する方が良いと思います。)

実践

Podfile

CocoaPodsのライブラリをWorkspaceに自動統合しないようにするには、Podfileに次の記述を追加します。

integrate_targets: false

例えば、下記のようになります。

platform :ios, '10.3'

install! 'cocoapods', generate_multiple_pod_projects: true, incremental_installation: true, integrate_targets: false

inhibit_all_warnings!
...

integrate_targets: false とした場合、CocoaPodsは特定のプロジェクトと関係しなくなるので、自由にターゲットを構成してグループ化できます。

platform :ios, '10.3'

install! 'cocoapods', generate_multiple_pod_projects: true, incremental_installation: true, integrate_targets: false

inhibit_all_warnings!
use_modular_headers!

target 'Shared' do
  use_frameworks!

  pod 'FolioAPI', git: 'git@github.com:FOLIO-Mobile/Folio-Mobile-API-Swagger.git', tag: '1.58.1' , inhibit_warnings: false
  current_target_definition.swift_version = '5.0'

  pod 'Firebase/Messaging'
  pod 'Firebase/InAppMessagingDisplay'
  pod 'Firebase/Performance'
  pod 'FirebaseAnalytics'
  pod 'Fabric'
  pod 'Crashlytics'
  pod 'Marketo-iOS-SDK', podspec: 'Marketo-iOS-SDK.podspec'
  pod 'DeallocationChecker'
  current_target_definition.swift_version = '4.2'
end

target 'Shared-Static' do
  pod 'Shimmer'
end

target 'Tests' do
  use_frameworks!
  pod 'Mockingjay'
  pod 'iOSSnapshotTestCase'
  current_target_definition.swift_version = '4.2'
end

pod 'SwiftGen'
pod 'SwiftLint'
pod 'LicensePlist'

上記の例では、SharedターゲットはFramework形式でビルドされます。DeallocationCheckerはSwift 4.2モードでビルドされます。

Shared-StaticターゲットはStaticライブラリとしてビルドされます。

TestsはFramework形式でビルドされます。iOSSnapshotTestCaseはSwift 4.2モードでビルドされます。

このように、各ライブラリごとに柔軟に設定を変更してビルドできるのもCocoaPodsとCocoaPodsをWorkspaceに統合しない利点です。

ビルド

integrate_targets: falseを設定した場合、ライブラリのプロジェクトはPodsディレクトリに展開されるだけなので、自分でビルドする必要があります。

Podsディレクトリには導入した各ライブラリのプロジェクトやソースコードの他にPods.xcodeprojが作られています。

Pods.xcodeprojは導入したPodがすべて含まれているプロジェクトです。

Pods.xcodeprojをビルドするとすべてのライブラリがビルドされます。

下記はPods.xcodeprojをiOSとSimulatorの両方で特定のディレクトリ以下にビルドするスクリプトです。

#!/bin/bash

set -exo pipefail

PROJECT_ROOT=$(cd $(dirname $0); cd ..; pwd)
PODS_ROOT="$PROJECT_ROOT/Pods"
PODS_PROJECT="$PODS_ROOT/Pods.xcodeproj"
SYMROOT="$PODS_ROOT/Build"

(cd "$PROJECT_ROOT"; bundle exec pod repo update)
(cd "$PROJECT_ROOT"; COCOAPODS_DISABLE_STATS=true bundle exec pod install)

xcodebuild -project "$PODS_PROJECT" \
  -sdk iphoneos -configuration Release -alltargets \
  ONLY_ACTIVE_ARCH=NO ENABLE_TESTABILITY=NO SYMROOT="$SYMROOT" \
  CLANG_ENABLE_MODULE_DEBUGGING=NO \
  BITCODE_GENERATION_MODE=bitcode | bundle exec xcpretty
xcodebuild -project "$PODS_PROJECT" \
  -sdk iphonesimulator -configuration Release -alltargets \
  ONLY_ACTIVE_ARCH=NO ENABLE_TESTABILITY=NO SYMROOT="$SYMROOT" \
  CLANG_ENABLE_MODULE_DEBUGGING=NO | bundle exec xcpretty

このスクリプトでビルドした成果物はPods/Build/Release-iphoneosまたはRelease-iphonesimulatorに出力されます。

このディレクトリは下記のようにEFFECTIVE_PLATFORM_NAME変数を用いて自動的に切り替わるように変数化しておきます。

PODS_ROOT = $(SRCROOT)/Pods
PODS_CONFIGURATION_BUILD_DIR = $(PODS_ROOT)/Build/Release$(EFFECTIVE_PLATFORM_NAME)

リンクの設定

上記で変数化したPODS_CONFIGURATION_BUILD_DIRPODS_ROOTを用いて各ライブラリを手作業でリンクします。

下記のxcconfigファイルは本体アプリケーションのビルド設定の抜粋です。

Firebaseはこのアプリケーションが動的リンクするフレームワークに静的リンクされるので、アプリケーションには直接リンクせず参照の設定があるだけになっています。

FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/Carthage/Build/iOS" "$(PODS_CONFIGURATION_BUILD_DIR)/FolioAPI" "$(PODS_CONFIGURATION_BUILD_DIR)/Keys-framework" "$(PODS_ROOT)/Marketo-iOS-SDK" "$(PODS_ROOT)/Crashlytics/iOS" "$(PODS_ROOT)/Fabric/iOS" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseCore" "$(PODS_ROOT)/FirebaseAnalytics/Frameworks" "$(PODS_ROOT)/FirebaseABTesting/Frameworks" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseMessaging" "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInAppMessaging" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseInAppMessagingDisplay" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseInstanceID" "$(PODS_ROOT)/FirebasePerformance/Frameworks" "$(PODS_ROOT)/FirebaseRemoteConfig/Frameworks"
HEADER_SEARCH_PATHS = "$(PODS_ROOT)/Headers/Public" "$(PODS_ROOT)/Headers/Public/Shimmer" "$(PODS_ROOT)/Firebase/CoreOnly/Sources" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseMessaging/FirebaseMessaging.framework/Headers" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseInAppMessagingDisplay/FirebaseInAppMessagingDisplay.framework/Headers"
LIBRARY_SEARCH_PATHS = "$(PODS_CONFIGURATION_BUILD_DIR)/Shimmer"
OTHER_LDFLAGS = -ObjC -l"Shimmer" -framework "Marketo"
OTHER_SWIFT_FLAGS = -Xcc -fmodule-map-file="$(PODS_ROOT)/Headers/Public/Shimmer/Shimmer.modulemap"
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/Modules"

下記のxcconfigファイルは本体アプリケーションに動的リンクされるEmbedded Frameworkです。このフレームワークにFirebaseを静的リンクしています。

Firebaseをアプリケーションにもリンクしてしまうと、シンボルの重複が発生します。

FRAMEWORK_SEARCH_PATHS = "$(SRCROOT)/Carthage/Build/iOS" "$(PODS_CONFIGURATION_BUILD_DIR)/FolioAPI" "$(PODS_CONFIGURATION_BUILD_DIR)/Keys-framework" "$(PODS_ROOT)/Marketo-iOS-SDK" "$(PODS_ROOT)/Crashlytics/iOS" "$(PODS_ROOT)/Fabric/iOS" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseCore" "$(PODS_ROOT)/FirebaseAnalytics/Frameworks" "$(PODS_ROOT)/FirebaseABTesting/Frameworks" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseMessaging" "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInAppMessaging" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseInAppMessagingDisplay" "$(PODS_CONFIGURATION_BUILD_DIR)/FirebaseInstanceID" "$(PODS_ROOT)/FirebasePerformance/Frameworks" "$(PODS_ROOT)/FirebaseRemoteConfig/Frameworks" "$(PODS_ROOT)/GoogleAppMeasurement/Frameworks" "$(PODS_CONFIGURATION_BUILD_DIR)/GTMSessionFetcher" "$(PODS_CONFIGURATION_BUILD_DIR)/GoogleToolboxForMac" "$(PODS_CONFIGURATION_BUILD_DIR)/GoogleUtilities" "$(PODS_CONFIGURATION_BUILD_DIR)/Protobuf" "$(PODS_CONFIGURATION_BUILD_DIR)/nanopb"
HEADER_SEARCH_PATHS = "$(PODS_ROOT)/Firebase/CoreOnly/Sources"
SWIFT_INCLUDE_PATHS = "$(SRCROOT)/Modules"
OTHER_LDFLAGS = -ObjC -framework "FolioAPI" -framework "AdjustSdk" -framework "Crashlytics" -framework "Fabric" -framework "FirebaseCore" -framework "FirebaseCoreDiagnostics" -framework "FirebaseAnalytics" -framework "FirebaseInstanceID" -framework "FirebaseMessaging" -framework "FirebaseABTesting" -framework "FirebasePerformance" -framework "FirebaseRemoteConfig" -framework "GoogleAppMeasurement" -framework "GoogleUtilities" -framework "GTMSessionFetcher" -framework "GoogleToolboxForMac" -framework "GoogleUtilities" -framework "Protobuf" -framework "nanopb" -framework "AdSupport" -framework "AddressBook" -framework "CoreData" -framework "CoreTelephony" -framework "JavaScriptCore"

フレームワークのコピー

Dynamic Frameworkは実行時にリンクされるのでアプリケーション本体にバンドルされてなければなりません。Dynamic Frameworkがある場合は、下記のスクリプトをBuild Phaseで実行してアプリケーションバンドルにコピーします。

code_sign() {
  # Use the current code_sign_identitiy
  echo "Code Signing $1 with Identity ${EXPANDED_CODE_SIGN_IDENTITY_NAME}"
  echo "/usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} --preserve-metadata=identifier,entitlements $1"
  /usr/bin/codesign --force --sign ${EXPANDED_CODE_SIGN_IDENTITY} --preserve-metadata=identifier,entitlements "$1"
}

if [ "$ACTION" = "install" ]; then
  echo "Copy .bcsymbolmap files to .xcarchive"
  find . -name '*.bcsymbolmap' -type f -exec mv {} "${CONFIGURATION_BUILD_DIR}" \;
fi

echo 'Copying frameworks'

if [ $SCRIPT_INPUT_FILE_LIST_COUNT -ne 0 ]; then
  for i in $(seq 0 $(expr $SCRIPT_INPUT_FILE_LIST_COUNT - 1)); do
    inputFileListVar="SCRIPT_INPUT_FILE_LIST_${i}"
    inputFileList="${!inputFileListVar}"
    cat "${inputFileList}" | while read inputFile; do
      cp -rf "$inputFile" "${BUILT_PRODUCTS_DIR}/${FRAMEWORKS_FOLDER_PATH}/"

      for file in $(find ${inputFile} -type f -perm +111); do
        # Skip non-dynamic libraries
        if ! [[ "$(file "$file")" == *"dynamically linked shared library"* ]]; then
          continue
        fi
        if [ "${CODE_SIGNING_REQUIRED}" == "YES" ]; then
          code_sign "${file}"
        fi
      done
    done
  done
fi

リソースのコピー

CocoaPodsはリソースもサポートしているので、リソースをコピーする必要があるライブラリがあるかもしれません。

この例ではFirebaseがそうなので、Build Phaseで下記のようなスクリプトを実行して解決します。

#!/bin/bash
set -ex

cp -rf "${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInAppMessagingDisplay/InAppMessagingDisplayResources.bundle" \
  "${BUILT_PRODUCTS_DIR}/${EXECUTABLE_FOLDER_PATH}/"

おわりに

このようにすることで、CocoaPodsで導入したライブラリであっても、Carthageのようにビルド済みライブラリとして取り扱えます。

プロジェクトにCocoaPods関連の設定が入らないので、.xcodeprojをクリーンに保てることや、ビルド済みライブラリとして取り回せるのでクリーンビルドのさいもライブラリまですべてビルドされてしまうことはありません。

CocoaPodsが自動設定していたものを手作業で設定していく手間は増えますが、変更管理もできるようになるので、デメリットばかりでもありません。

副次的なメリットとして、ライブラリのキャッシュのコントロールがやりやすくなりました。

ビルド済みライブラリをアーカイブすればいいので、CIではライブラリのビルドする回数を大幅に削減できた上、現在は手元の環境もビルド済みライブラリをキャッシュからダウンロードするだけになり、手元でライブラリをビルドするということがほとんど必要なくなりました。

CIやビルド済みライブラリのキャッシュについては、また別の記事で解説します。

下記の記事でも少し触れています。

blog.kishikawakatsumi.com

OpenAPI (Swagger) のコード生成から通信処理を分離してスキーマ定義だけを利用する

背景

今関わっているプロジェクトではOpenAPIを利用して、APIのスキーマを定義しています。

OpenAPIではスキーマ定義からクライアントコードを生成できます。

しかし、デフォルトのコード生成はスキーマ定義とネットワーク通信のコードが強く結びついており、使いにくい場面があると感じていました。

認証等がなく、単純なGETだけのエンドポイントを相手にしている場合はそうなっているのは便利だと思いますが、今のプロジェクトでは

  • リクエストヘッダに認証トークンおよびアプリの情報を示す情報を追加する
  • (デバッグビルドでは)リクエスト前と後にログ出力をする
  • アクセストークンの期限が切れた場合は自動的にアクセストークンをリフレッシュし、シームレスにリトライする
  • すべてのエンドポイントで発生しうるエラー(サーバーエラーの5xxや、クライアントエラーの4xx、強制アップデートや緊急メンテナンスなど)と、個々のエンドポイントでのみ起こるエラー、認証のエンドポイントでのみ起こるエラーを適切にハンドリングする

というそこそこ複雑な処理をする必要があります。

問題

OpenAPIのコード生成はデフォルトではAlamofireに依存していて、通信処理の大部分はAlamofireが担当しますが、OAuth2のサポートはデフォルトでは無いので認証は別のOAuth2ライブラリと組み合わせることで実現していました。

問題に感じていたことは主にAPIの追加・更新時に挙動がおかしい場合の調査・デバッグです。

前述のようにAPIの通信に関係する処理は、OpenAPIの自動生成コード(スキーマの定義とパラメータの加工とエンコード、レスポンスのデコードなどが含まれる)、Alamofireのコード、OAuth2ライブラリのコード、アプリケーションコード、の4つにまたがり、そのうちの3つは第三者が書いたライブラリのコードです。

複数のライブラリのコードをスイッチして調査することは問題の調査をかなりややこしくしていました。

問題の解決

その問題を解決するために、OpenAPIによるコード生成はAPIのスキーマ定義だけのシンプルなもの(自動生成コードをデバッグするのはつらい)にし、通信処理もシンプルなクライアントを自分で書くことにしました。

そうしてできあがったのがこちらのライブラリです。

github.com

もともとのOpenAPIが生成する通信のコードは、非常に汎用的になっているため、現在のプロジェクトには関係のないコードが数多く存在しました。

このAPIClientでは、一般的なAPIクライアントでは実装すべき処理も、プロジェクトで使用してないものは対応しないことでシンプルに誰でも読めるように書かれています。

OpenAPIのスキーマ定義から生成したコードを利用することが前提であることも、シンプルさに役立っています。

OpenAPIのコード生成は次のように変更しました。通信処理に必要なヘルパークラスはすべて無くして、パラメータとレスポンスに使用されるモデル(変更なし)とエンドポイントの定義のみを生成するようにしました。

エンドポイントのコードは下記のようになります。

open class func addPet(pet: Pet) -> RequestProvider<Void> {
    let path = "/pet"
    let parameters = pet

    return RequestProvider<Void>(endpoint: path, method: "POST", parameters: RequestProvider.Parameters(parameters))
}

open class func findPetsByTags(tags: [String]) -> RequestProvider<[Pet]> {
    let path = "/pet/findByTags"
    
    let parameters: [String: Any?] = [
        "tags": tags
    ]
    return RequestProvider<[Pet]>(endpoint: path, method: "GET", parameters: RequestProvider.Parameters(parameters))
}
...

APIリクエストに必要なエンドポイントの定義は、URL、HTTPメソッドの種類、パラメータの型およびエンコーディング、レスポンスの型、が必要です。

作り直したコード生成のテンプレートは上記の情報をRequestProviderという型にエンコードして表現します。

RequestProvider<Response>にはAPIリクエストに必要な情報がすべて含まれていますので、これだけ受け取ればAPIリクエストを実行できます。

APIクライアントの通信処理はほぼすべてClient.swiftに書かれています。他のファイルはほぼデータ構造やプロトコルを定義しているだけのファイルなので、処理は記述されていないので見る必要がありません。

Client.swiftURLSessionを使った典型的な通信処理が書かれていて、非同期のコールバックによるネストと、シームレスなリトライ処理のためループする構造になっているところがやや複雑に見えますが、基本的に上から下に読んでいけばわかるように単純に記述されています。

APIClientとOpenAPIのスキーマ定義を個別にビルド可能にしたかったので、RequestBuilder<Response>からAPIClientが使うRequest<Response>に型を変換するコードをアプリケーション側に書きます。

extension RequestProvider {
    func request() -> Request<Response> {
        if let parameters = parameters {
            switch parameters {
            case .query(let raw):
                return Request(endpoint: endpoint, method: method, parameters: Request.Parameters(raw))
            case .form(let raw):
                return Request(endpoint: endpoint, method: method, parameters: Request.Parameters(raw))
            case .json(let raw):
                return Request(endpoint: endpoint, method: method, parameters: Request.Parameters(raw))
            }
        }
        return Request(endpoint: endpoint, method: method)
    }
}

ログ出力やエラーハンドリング、トークンリフレッシュを伴う自動リトライなどはOkHttpを参考にしてInterceptorという仕組みでリクエストの前後の処理をフックできるようにしました。iOSのスタイルに沿うように、単なる複数登録できるDelegateにしました。

ここまでで、

  • OpenAPIのコード生成から通信処理をなくす
  • OpenAPIのコード生成をシンプルに
  • 通信処理の簡略化
  • リトライやエラーハンドリングの仕組みを統一

によって、従来のデバッグが困難という問題を解決できました。

おわりに

ここで示したコードとライブラリは、現在のプロジェクトに最適化しているため、他のプロジェクトでそのまま使用することには不向きです。

また、過不足なく機能を実装することでシンプルさを保つ目的のため、Pull Requestも受け付ける予定はありません。

しかし、考え方や実装方法は参考にはなると考え、公開しています。もし、同様のアプローチで問題を解決しようとするなら、フォークするか、コードをコピーして使用することをおすすめします。

下記は認証のリトライと、ログ出力のInterceptorの実装例です。

public class Authenticator: Intercepting, Authenticating {
    private let credentials: Credentials

    public init(credentials: Credentials) {
        self.credentials = credentials
    }

    public func intercept(client: Client, request: URLRequest) -> URLRequest {
        return sign(request: request)
    }

    public func shouldRetry(client: Client, request: URLRequest, response: HTTPURLResponse, data: Data?) -> Bool {
        if response.statusCode == 401, let url = request.url, !url.path.hasSuffix("/login"), credentials.fetch()?.refreshToken != nil {
            return true
        }
        return false
    }

    public func authenticate(client: Client, request: URLRequest, response: HTTPURLResponse, data: Data?, completion: @escaping (AuthenticationResult) -> Void) {
        switch response.statusCode {
        case 401:
            if let url = request.url, !url.path.hasSuffix("/login"), let refreshToken = credentials.fetch()?.refreshToken {
                client.perform(request: AuthenticationAPI.v1LoginPost(username: nil, password: nil, refreshToken: refreshToken).request()) {
                    switch $0 {
                    case .success(let response):
                        self.credentials.login(response.body)
                        completion(.success(self.sign(request: request)))
                    case .failure(let error):
                        switch error {
                        case .networkError, .decodingError:
                            completion(.failure(error))
                        case .responseError(let code, let headers, let data):
                            switch code {
                            case 400, 401:
                                self.credentials.update(token: nil)
                                completion(.failure(.responseError(401, headers, data)))
                            case 400...499:
                                completion(.failure(error))
                            case 500...599:
                                completion(.failure(error))
                            default:
                                completion(.failure(error))
                            }
                        }
                    }
                }
            } else {
                completion(.cancel)
            }
        default:
            completion(.cancel)
        }
    }

    private func sign(request: URLRequest) -> URLRequest {
        var request = request
        if let url = request.url, !url.path.hasSuffix("/login") {
            if let accessToken = credentials.fetch()?.accessToken {
                request.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization")
            }
        }
        return request
    }
}
public struct Logger: Intercepting {
    public init() {}

    public func intercept(client: Client, request: URLRequest) -> URLRequest {
        os_log("⚡️ %@", type: .debug, "\(requestToCurl(client: client, request: request))")
        return request
    }

    // swiftlint:disable large_tuple
    public func intercept(client: Client, request: URLRequest, response: URLResponse?, data: Data?, error: Error?) -> (URLResponse?, Data?, Error?) {
        if let response = response as? HTTPURLResponse {
            let path = request.url?.path ?? ""
            let statusCode = response.statusCode
            var message = "\(statusCode < 400 ? "🆗" : "🆖") [\(statusCode)] \(path)"
            if let data = data, let text = String(data: data, encoding: .utf8) {
                message += " \(text.prefix(100) + (text.count > 100 ? "..." : ""))"
            }
            os_log("⚡️ %@", type: .debug, message)
        } else if let error = error {
            os_log("⚡️ %@", type: .debug, "\(error)\n🚫 \(requestToCurl(client: client, request: request))")
        }
        return (response, data, error)
    }

    private func requestToCurl(client: Client, request: URLRequest) -> String {
        guard let url = request.url else { return "" }

        var baseCommand = "curl \(url.absoluteString)"
        if request.httpMethod == "HEAD" {
            baseCommand += " --head"
        }
        var command = [baseCommand]
        if let method = request.httpMethod, method != "GET" && method != "HEAD" {
            command.append("-X \(method)")
        }
        if let headers = request.allHTTPHeaderFields {
            for (key, value) in client.headers {
                if let key = key as? String, key != "Cookie" {
                    command.append("-H '\(key): \(value)'")
                }
            }
            for (key, value) in headers where key != "Cookie" {
                command.append("-H '\(key): \(value)'")
            }
        }
        if let data = request.httpBody, let body = String(data: data, encoding: .utf8) {
            command.append("-d '\(body.removingPercentEncoding ?? body)'")
        }

        return command.joined(separator: " ")
    }
}

SceneKitとCALayerで作る3Dのスライドショー

大型ディスプレイに投影するデジタルサイネージを作る仕事をしました。

できあがったのがこれです。

github.com

f:id:KishikawaKatsumi:20190415032635g:plain


まず、アートディレクターと相談して、下記の映像を参考にして3D空間を飛び回るようなスライドショーでいこうと決めました。

www.youtube.com

www.youtube.com


最初のプロトタイプはCALayerだけで作りました。

f:id:KishikawaKatsumi:20190415031617p:plain:w500

CALayerは3Dの変形をサポートしていて、かつmacOSのCALayerではCore Imageのフィルタがエフェクトに使えるので、各層のレイヤーに次のように書くだけで遠くなるにつれてブラーをかけてぼやかせる、ということが簡単に実現できます。

let frontLayer = CALayer()
frontLayer.frame = layerFrame
if let filter = CIFilter(name: "CIGaussianBlur", parameters: [kCIInputRadiusKey: 4]) {
    frontLayer.backgroundFilters = [filter]
}
contentLayer.addSublayer(frontLayer)


おそらく、表現だけならCALayerで作るのがもっとも簡単に美しい結果が得られます。

これを単純にX, Y軸平面をアニメーションで動かすだけでも(CAScrollLayerに載せるだけで簡単にできる)そこそこいい感じだったのですが、やはり前後(Z軸)の動きがある方がよりダイナミックで楽しい動きになるだろうということで、SceneKitと組み合わせることにしました。

実はSceneKitのNodeにはテクスチャとしてCALayerを設定することができるので、事実上、任意のAppKitもしくはUIKitのUIを3D空間に表示できます。

そこまで分かっていれば、あとは先ほどのCALayerで作ったパーツをSceneKitに標準で用意されている平面のオブジェクトであるSCNPlaneに設定して、Z軸をいい感じにして配置します。

for board in boards {
    for image in images.shuffled().prefix(50) {
        let layer = PhotoLayer()

        if let url = image.urls["regular"] {
            ImagePipeline.shared.load(url, into: layer.imageLayer)
        }
        layer.setTitle(image.description ?? image.alt_description ?? "")

        let size = CGSize(width: planeSize.width * 1.5, height: planeSize.height * 1.5)
        if let point = randomPoint(size: size, in: board.nodes) {
            let node = planeNode(layer: layer, position: SCNVector3(x: point.x, y: point.y, z: board.zPosition))
            node.name = image.id
            scene?.rootNode.addChildNode(node)

            board.nodes.append(node)
        }
    }
}

f:id:KishikawaKatsumi:20190415033716p:plain:w500

あとは3D空間を飛び回ってそれぞれの写真にズームしていくようなアニメーションを付けるだけです。 SceneKitのオブジェクトのほとんどのプロパティはCALayerの各プロパティと同様にアニメーション可能に設定されています。

3D空間を移動してそれぞれの写真にフォーカスする動きはカメラ(SceneKitではカメラや光源もNodeの1つとして扱われます)を動かすことで実現しています。

private func randomCameraAction(node: SCNNode, completion: @escaping () -> Void) {
    switch Int.random(in: 0...2) {
    case 0:
        self.cameraNode.yaw(to: node.position, angle: 0, completion: completion)
    case 1:
        var position = node.position
        position.x += 10
        self.cameraNode.yaw(to: position, angle: 15, completion: completion)
    case 2:
        var position = node.position
        position.x -= 10
        self.cameraNode.yaw(to: position, angle: -15, completion: completion)
    default:
        break
    }
}


最後に、フォーカスした写真をちょっと他より大きくするという「味付け」のアニメーションを追加して完成です。

private func loop(start node: SCNNode, level: Int, wait: TimeInterval = 2) {
    ...
    cameraNode.wait(wait) { [weak self] in
        ...
        node.resetScale()
        nextNode.scaleUp()

        self.randomCameraAction(node: nextNode) { [weak self] in
            guard let self = self else { return }
            self.loop(start: nextNode, level: nextLevel)
        }
    }
}

private func randomCameraAction(node: SCNNode, completion: @escaping () -> Void) {
    ...
}

func scaleUp(duration: TimeInterval = 2) {
    runAction(.scale(to: 1.8, duration: duration))
}

func resetScale(duration: TimeInterval = 2) {
    runAction(.scale(to: 1, duration: duration))
}


できあがったものは下記のリポジトリで公開しています。

github.com

f:id:KishikawaKatsumi:20190415032635g:plain

CのライブラリをiOS向けにビルドする

ほとんどのUNIX系OSにおけるCライブラリはAutotoolsを使って開発されています(./configure && make installの手順でビルドする)。

普通に./configure && make installすると実行している環境(MacやLinux)用のバイナリが生成されますので、そのままではiOSで利用できません。

iOSで使用できるようにするにはiOS環境用にビルド、いわゆるクロスコンパイルする必要があります。

Autotools(./configure)ではクロスコンパイルのことが初めから考慮されていてそのための機能がサポートされているので、./configureの設定を正しくできれば、意外と簡単にさまざまなCのライブラリをiOS上で動くようにビルドできます。

設定について

iOSではデバイスとシミュレータでCPUアーキテクチャが異なるのでそれぞれのアーキテクチャのバイナリをビルドします。

デバイス用にビルドするときの./configureの例

./configure \
  --host=x86_64-apple-darwin \
  CC=`xcrun -find clang` \
  CFLAGS="-O3 -arch armv7 -arch armv7s -arch arm64 -isysroot `xcrun -sdk iphoneos --show-sdk-path` -fembed-bitcode -mios-version-min=8.0" \
  CXX=`xcrun -find clang++` \
  CXXFLAGS="-O3 -arch armv7 -arch armv7s -arch arm64 -isysroot `xcrun -sdk iphonesimulator --show-sdk-path` -fembed-bitcode -mios-version-min=8.0" \
  --prefix="$TARGETDIR_IPHONEOS" \
  --disable-shared --enable-static

シミュレータ用にビルドするときの./configureの例

./configure \
  --host=x86_64-apple-darwin \
  CC=`xcrun -find clang` \
  CFLAGS="-O3 -arch i386 -arch x86_64 -isysroot `xcrun -sdk iphonesimulator --show-sdk-path` -fembed-bitcode-marker -mios-simulator-version-min=8.0" \
  CXX=`xcrun -find clang++` \
  CXXFLAGS="-O3 -arch i386 -arch x86_64 -isysroot `xcrun -sdk iphoneos --show-sdk-path` -fembed-bitcode-marker -mios-simulator-version-min=8.0" \
  --prefix="$TARGETDIR_IPHONESIMULATOR" \
  --disable-shared --enable-static 

--hostはクロスコンパイル(ビルド環境と実行環境が異なる)を有効にするために必要なオプションで、ビルドしたバイナリが実行される環境を指定します。

CCはCコンパイラの指定、CFLAGSはCコンパイラのオプションです。CXXCXXFLAGSはC++コンパイラとコンパイラオプションの指定です。

デバイス用のビルドとシミュレータ用のビルドではアーキテクチャ(-archオプション)とシステムルート(-isysrootオプション)、ビットコードの埋め込み方(-fembed-bitcodeまたは-fembed-bitcode-marker)、Deployment Target(-mios-version-minまたは-mios-simulator-version-min)が異なります。

--prefixmake installしたときのバイナリのインストール先を指定します。iOS用にライブラリをビルドする際は、デバイス用とシミュレータ用の2種類のバイナリを生成する必要があるので、続けてビルドした際に上書きされてしまわないようにそれぞれ別のディレクトリを指定しておくと便利です。

--disable-sharedはダイナミック(共有)ライブラリ(*.dylib)を生成しないオプション、--enable-staticは静的ライブラリを生成するオプションです。静的ライブラリの方が使い勝手がいいので私はこちらを指定することが多いですが、必要に応じて使い分けてください。

もし、watchOSやtvOS用のビルド(制限が強いのでこちらはそのままビルドできることは少ないですが)をしたい場合は、この要領でアーキテクチャやシステムルートを、そのデバイス向けに変更します。

実践(secp256k1をiOS用にビルド)

それでは試しにCで書かれたオープンソースのライブラリをiOS向けにビルドしてみましょう。 例として、Bitcoinで使われている楕円曲線暗号 secp256k1 のライブラリをiOSで使えるようにビルドしてみましょう。

github.com

Autotoolsがインストールされていない場合は先にautoconfautomakeをインストールします。

$ brew install autoconf automake

リポジトリをクローンします。

$ git clone https://github.com/bitcoin-core/secp256k1 src

クローンしたリポジトリに移動します。

$ cd src

autoreconfコマンドでconfigureスクリプトを生成します。

$ autoreconf -if

成果物を保存するためのディレクトリを作っておきます。このディレクトリを--prefixオプションで指定します。

$ mkdir -p build/iphoneos

iOSデバイス向けの設定で./configureを実行します。

$ ./configure \
  --host=arm-apple-darwin \
  CC=`xcrun -find clang` CFLAGS="-O3 -arch armv7 -arch armv7s -arch arm64 -isysroot `xcrun -sdk iphoneos --show-sdk-path` -fembed-bitcode -mios-version-min=8.0" \
  CXX=`xcrun -find clang++` \
  CXXFLAGS="-O3 -arch armv7 -arch armv7s -arch arm64 -isysroot `xcrun -sdk iphonesimulator --show-sdk-path` -fembed-bitcode -mios-version-min=8.0" \
  --prefix="$PWD/build/iphoneos" \
  --disable-shared --enable-static

特にエラーがなければ続けて、make installを実行します。

$ make install

成功したらbuild/iphoneos/lib/以下のlibディレクトリにlibsecp256k1.aが生成されています。 正しくiOSデバイス向けにビルドされているか、アーキテクチャを確認します。

$ xcrun lipo -info build/iphoneos/lib/libsecp256k1.a 

このコマンドの出力は下記のようになります。iOSデバイス用のビルドなのでARMアーキテクチャ用のバイナリが出力されています。

Architectures in the fat file: build/iphoneos/lib/libsecp256k1.a are: armv7 armv7s arm64 

続けてシミュレータ用のビルドを作成します。

成果物が上書きされないように、出力先のディレクトリを別名で作成します。

$ mkdir -p build/iphonesimulator

シミュレータ用のビルド設定にして./configureをやり直します。

$ ./configure \
  --host=x86_64-apple-darwin \
  CC=`xcrun -find clang` \
  CFLAGS="-O3 -arch i386 -arch x86_64 -isysroot `xcrun -sdk iphonesimulator --show-sdk-path` -fembed-bitcode-marker -mios-simulator-version-min=8.0" \
  CXX=`xcrun -find clang++` \
  CXXFLAGS="-O3 -arch i386 -arch x86_64 -isysroot `xcrun -sdk iphoneos --show-sdk-path` -fembed-bitcode-marker -mios-simulator-version-min=8.0" \
  --prefix="$PWD/build/iphonesimulator" \
  --disable-shared --enable-static 
$ make install

ビルドが成功したら、build/iphonesimulator/libにバイナリが生成されています。同様にアーキテクチャを確認してみましょう。

xcrun lipo -info build/iphonesimulator/lib/libsecp256k1.a

このコマンドの出力は以下のようになります。

Architectures in the fat file: build/iphonesimulator/lib/libsecp256k1.a are: i386 x86_64

デバイス用のバイナリとシミュレータ用のバイナリをそれぞれ生成しました。 このままでも工夫すれば使えますが、不便なので2つのバイナリを結合した1つのファイル(Fatバイナリ)を作ります。

Fatバイナリの生成は、lipoコマンドを使います。

$ xcrun lipo -create "build/iphoneos/lib/libsecp256k1.a" \
  "build/iphonesimulator/lib/libsecp256k1.a" \
  -o "build/libsecp256k1.a"
xcrun lipo -info build/libsecp256k1.a

このコマンドの出力は次のようになります。ARMアーキテクチャとIntelアーキテクチャの両方が1つのバイナリに含まれています。

Architectures in the fat file: build/libsecp256k1.a are: armv7 armv7s i386 x86_64 arm64 

後は、ライブラリをリンクし、ヘッダファイル(includeディレクトリにコピーされています)を適切に参照すればSwift/Objective-Cからこのライブラリを使用できます。 Swiftで使う場合にはアプリで使う場合(ブリッジヘッダ)とライブラリで使う場合(Module Map File)で多少利用方法が異なるので、実際にアプリ・ライブラリに組み込む方法は別の記事で解説します。

まとめ

このように、多くのCのライブラリは、iOS向けに書かれていないライブラリでも、簡単にiOS向けにビルドして使用できます。

私は上記で説明したような手順をスクリプトにまとめて、それぞれのライブラリごとの微修正して使用しています。

OpenSSLなどAutotoolsを使用していないライブラリも存在しますが、基本的な考え方は同様です。そのビルドシステム(多くは単なるShellスクリプト)にアーキテクチャなどiOS用にビルドするためのコンパイラオプションを渡します。

下記の例は、OpenSSL(のlibcrypto)をiOS用にビルドするためのスクリプトの例です。OpenSSL のビルドスクリプトに環境変数を通じてコンパイラオプションを渡しています。

BitcoinKit/build_crypto_impl.sh at master · yenom/BitcoinKit · GitHub

Auto Layoutの静的な制約で実現するカラム幅が可変のテーブル

次に示すような見出しと各カラムが右寄せ、ラベルの文字数によってカラムの幅が伸縮し、広くなった場合は隣の列を押し出し、短くなった場合は少なくとも見出しの幅に収まり、各列の間には一定のマージンを置くというテーブルレイアウトを、静的なAuto Layoutの制約だけで作ることを考えます。

https://user-images.githubusercontent.com/40610/47085526-d7d45180-d251-11e8-9293-7a82bbc6d6c7.gif


このような、UIコンポーネントが持つコンテンツの大きさによって隣接するコンポーネントを押し出すような場面ではAuto Layoutがとても効果的に働きます。

Auto Layoutなしで実現しようとすると、列ごとの各行の文字幅を計算し、最大の幅に合わせて再配置する、という処理をコンテンツが変わるたびに行うということになりますが、Auto Layoutの制約を使用する場合ではそもそもレイアウトの再計算を自分でやる必要はないので、コンテンツの変わったタイミングなどを気にする必要はありません。

ただデータを再代入するだけで適切にレイアウトが変化します。状態を監視してバインドするような仕組みがあれば完全に宣言的に書けるでしょう。

サンプルコードは下記のリポジトリで公開しています。

github.com

見出し行を作る

まず見出しとなる行を作ります。 UIViewに適当な高さ(今回の例では44pt)の制約を付けて上端と左右両端を固定します。行を示すこのビューは厳密には無くてもよいですがあったほうが作りやすいです。多くのビューに対してAuto Layoutの制約を組み立てる場合はフラットな構造よりも、適当にビューでグルーピングすると問題がシンプルになります。

高さも固定ではなくコンテンツの高さで決まるようにもできますが、今回の本質とは関係がないので簡単に44pt固定の行ということにします。

ラベルを3つ配置して、一番左のラベルは左寄せ、残りの2つは右寄せに設定します。

それぞれをタテ方向の中央配置にして、左右のラベルはそれぞれビューの左端と右端に固定、真ん中のビューは右のラベルから10pt固定のマージン、左のラベルに10pt以上のマージンの制約を付けます。

制約が成り立つかどうかだけでいうと、最後に付けた左のラベルと真ん中のラベルの10pt以上のマージンは必要ありませんが、コンテンツの幅が想定よりも大きくなったときでも、ラベル同士が重なってしまわないようにするためのものです。

f:id:KishikawaKatsumi:20181106031637p:plain

実際のStoryboardは次のようになります。

f:id:KishikawaKatsumi:20181106031824p:plain:w320

データ行を作る

データ行のレイアウトは見出し行と一緒なので見出し行のビューごとコピーして、それを修正していく形で作ると簡単です。

コピーしたビューは自身の内部の制約は保持されますが、外部との制約はすべて外れている状態なので、まず同様にビューの両端と、見出し行の下端と自身の上端を合わせる制約を付けます。

f:id:KishikawaKatsumi:20181106033042p:plain

実際のStoryboardは次のようになります。

f:id:KishikawaKatsumi:20181106033109p:plain:w320

ここで、ラベルにとても多くの文字を入力してみます。残念ながら10pt以上のマージンの制約があるにもかかわらず、ラベルのサイズがそれ以上に大きくなってしまうため制約がコンフリクトしてラベルが重なってしまいました。

f:id:KishikawaKatsumi:20181106033245p:plain:w320

この問題は、水平に並んだ2つ以上のラベルのうち、どれかのContent Compression Resistance Priorityを下げる(もしくは他のラベルのContent Hugging Priorityを上げる)ことで解決します。 そうするとそれぞれのラベルのサイズの合計が外側のビューより大きくなったとしても、外側のビューに収まるように優先度の低いラベルが小さくなって制約が解決します。

f:id:KishikawaKatsumi:20181106034208p:plain:w320

同様にして、残りの行を表すビューを追加します。行のビューはそれぞれをUIStackViewに入れるとさらに簡単です。サンプルコードではタテのレイアウトはUIStackViewに任せています。

f:id:KishikawaKatsumi:20181106034550p:plain:w320

カラム幅を最大文字数に合わせて揃える

さて、ここまでの状態ではカラム幅が不揃いなので、文字数に合わせて揃うようにしていきましょう。

f:id:KishikawaKatsumi:20181106034912p:plain:w320

見出し行と各データ行について、右端のラベルをすべて選択します。その状態で互いにEqual Widthの制約を付けます。つまり、同じ列のラベルはすべて同じ幅になるという制約になります。

f:id:KishikawaKatsumi:20181106035207p:plain:w320

この時点でStoryboardを見てわかるように、もっとも幅の大きいラベルに合わせて、列の幅が揃うようになりました。

真ん中のカラムの各ラベルに対しても、同様にEqual Widthの制約を互いに付けます。

f:id:KishikawaKatsumi:20181106035641p:plain:w320

これで完成です。 Storyboard上でラベルにいろいろなテキストを入力して意図したとおりにレイアウトが変わるかどうか試してみてください。 実行せずにさまざまな状態を確認できることはStoryboardで制約を組み立てる大きな利点です。

f:id:KishikawaKatsumi:20181106035932p:plain:w320

f:id:KishikawaKatsumi:20181106035928p:plain:w320

いかがでしょうか。文字数が多い場合でも少ない場合でもきれいに列の幅が調整され、端が揃っていることがわかります。

最初に制約を付けておくだけで、あとはコンテンツに応じて自動的に調整されるので、コードを書く必要がなく、バグが入り込む余地が少なくなります。

アップルのAuto Layout Cookbookというガイドには、今回紹介したテクニックをはじめ、具体的なレイアウトのサンプルに対する制約の例がたくさん載っています。 ひと通り手を動かしながら読んでみると、Auto Layoutをより高いレベルで使いこなせるようになるのでオススメです。

developer.apple.com

Auto Layoutの静的な制約で実現するテキストの量によって折りたたみ可能なテキストビュー

長いテキストが初期表示では折りたたまれて表示されていて、「つづきを読む」ボタンを押すことで表示エリアが拡大して全文が表示されるという挙動を、できるだけ動的な要素を排除して実現してみます。


f:id:KishikawaKatsumi:20181105035723g:plain:w320


サンプルコードは下記のリポジトリで公開しています。

github.com


今回の例ではUIStackViewを活用します。UIStackViewは内部のビューのisHiddenプロパティによってビューのサイズをゼロにできるので、うまく活用すればあたかも複数のレイアウトを切り替えているような挙動を実現できます。

テキストビュー(またはラベル)の左右両端と上端が一致するようにStack Viewに制約を付けます。

さらにテキストビューとStack Viewの高さが一致する制約を付けます。

Stack Viewの中のビューに高さの制約として折りたたんだときのサイズを指定します。例では200ptです。 テキストの分量がその高さに満たない場合はテキストの高さに合わせるべきですので、制約は等号ではなく以下を示す(<=)にしておきます。

これでテキストビューとまったく同じ位置にStack Viewが表示されます。


f:id:KishikawaKatsumi:20181105042813p:plain

Storyboardで作っている場合はここでStack Viewの中のビューのisHiddenプロパティを切り替えてみましょう。

isHiddentrue(チェック)にすると、テキストビューが本来のサイズまで高さが伸びることがわかります。

Stack Viewの中のビューのisHiddentrueになることで、Stack ViewのIntrinsic Content Sizeがゼロになります。そのため最終的にテキストビュー自身の高さにAuto Layoutが解決されます。テキストビューとStack View間の制約は、高さが同じというものですので、その場合でも問題なく制約を満たすことができています。


Stack Viewの便利な点はこのようにビューと制約の有効化・無効化をisHiddenのプロパティだけで行えることにあります。 アクションや状態の変化をisHiddenのON/OFFにマッピングすることで宣言的に異なるレイアウトに切り替えることができます。

このテクニックも応用範囲が広いので使いこなせると便利です。 下記の記事と合わせてご覧ください。

blog.kishikawakatsumi.com

Auto Layoutの静的な制約で実現する伸び縮みするヘッダービュー

TL;DR,

  • 優先度の異なる複数の制約を同時に定義することで、静的な定義だけで動的な振る舞いを実現できる
  • 動的な要素の少ない構造のビューはより堅牢である

はじめに

読みやすくメンテナンスしやすいソフトウェアを作るために重要なことの一つは構造をシンプルに保つことです。

iOSアプリのビューは壊れやすいソフトウェアの代表ですが、できるだけシンプルに作ることで変化に強い、堅牢で壊れにくいソフトウェアにできます。

動的な要素が少ないということは、ビューがシンプルであるということの指標の1つと言えます。

この記事では下記に示すような、スクロールに合わせて伸び縮みするヘッダーを、動的な要素を無くし、Auto Layoutの静的な制約のみで実現する方法を解説します。

https://user-images.githubusercontent.com/40610/47085331-48c73980-d251-11e8-8ffd-25c6c75b6044.gif

動的な要素とは、実行時におけるビューおよび制約の追加・削除、Frameや制約を更新することと、機種やスクリーンサイズ、標準UIコンポーネントのサイズなどを仮定して、条件によって処理を分岐することです。

スクリーンサイズや機種を判定して処理を変えたり、ナビゲーションバーの高さなどを固定値だと仮定したコードは変更に弱く、壊れやすいビューとなってしまいます。 また、ビューの追加削除をしたときに、制約がどのように変化するかは自明ではありません(実際にはそのビューに付いていた制約はすべて外れることと決まっていますが、それを頭の中でシミュレートしなければならないので大変です)。

動的な要素がなければ、そもそもそういったことは起こらないので、常に一定の結果を期待できます。

今回のサンプルコードは下記のリポジトリで公開しています。まずStoryboardを眺めて、動かしてみるとわかりやすいです。

github.com

サンプルコードではわかりやすさのためにすべての制約をStoryboardで記述しています。スクロールビューのcontentOffsetなどを監視するようなコードは一切ありません。ヘッダーの伸び縮みや、ナビゲーションバーの高さで固定する挙動はすべて制約によって実現されます。

ViewControllerにも処理は書かれていますが、レイアウトには関係のないスタイルの変更などであることがわかります。

Storyboardを使わなければいけないわけではなく、例えばviewDidLoad()loadView()だけに記述されていればそれは静的な制約と言えます。

それでは具体的な作り方をみていきます。

伸び縮みせず普通にスクロールするレイアウトを作る

まずは伸び縮みする効果は忘れて、Image Viewをスクロールビューの上部に普通に配置します。

f:id:KishikawaKatsumi:20181104164611p:plain:w320

スクロールビューをRoot Viewと同じ大きさになるように上下左右のエッジに合うように制約を付けます。 Image Viewをスクロールビューのサブビューに追加し、左右両端と上端に合うように制約を付けます。これだけではタテ方向の制約が足りないのでAspect Ratioの制約を追加します。

スクロールビューとサブビューの制約は通常のビューに対する制約と異なりスクロールビューのスクロール領域に対する制約になります。

スクロールビュー自体のサイズに対する制約とはならないので、スクロールビューのサイズを決定するために制約は端から端まで繋げて、サブビューのサイズでスクロールビューのスクロール領域のサイズが決定するように制約を付けます。

Image Viewはスクロールビューの左右のエッジに加えて、Viewの左右両端にも合うように制約を付けます。 下方向の制約はスクロールビューの下端まで切れ目なく繋がるように制約を付けます。

ここまでで次のような挙動のレイアウトになります。

f:id:KishikawaKatsumi:20181104172132g:plain:w320

Image Viewをスクロールによって伸び縮みさせる

これを基本形として、Image Viewがスクロールによって伸び縮みするように制約を変更していきます。 まず準備としてImage Viewをただのビュー(Image Contaner Viewとします)に置き換え、同じように制約を付けます。


f:id:KishikawaKatsumi:20181104173037p:plain:w320

これで実際にスクロールに追随して動くのはImage Viewではなく、Image Container Viewになります。 (Image Viewに付いていた制約はいったん全部外します。)

次にImage Viewをスクロールに追随せず、上部に固定したままになるように制約を追加します。

Image ViewとImage Container Viewの左右両端と下端を合わせる制約を追加します。 さらにImage Viewの上端と View の上端を合わせる制約を追加します。


f:id:KishikawaKatsumi:20181104173619p:plain:w320

この時点で下方向にスクロールしたとき、Image ViewはImage Container Viewとの制約によりImage Container Viewに合わせてスクロールしようとしますが、上端はViewとの制約により固定されているので、結果として上端は固定されたまま、下端が下がることで高さが大きくなり、スクロールに合わせて伸び縮みするようになります。

f:id:KishikawaKatsumi:20181104174245g:plain:w320

しかし、見てわかるようにこの時点では問題があり、上方向のスクロールができなくなってしまいます。(また上にスクロールした際にAutoLayoutの制約が衝突するエラーが出ていることがわかります。) これはImage ViewをViewの上端に固定したことによります。

これを直すために、AutoLayoutの優先度を調整して、制約がコンフリクトした際に適切に制約がブレイクして、別の制約に切り替わるようにします。


f:id:KishikawaKatsumi:20181104175350p:plain:w320

Image ViewとViewに付けた制約の優先度をRequired (1000)からHigh (750)に変更します。

これでこの制約を満たすことは必須ではなくなったので、上にスクロールしてImage View.Top == View.Topの制約が満たせなくなっても単にその制約が無視されるだけで問題ありません。これで以前のようにスクロールできるようになります。

Image Viewに対する上端の制約の優先度を下げたので、上にスクロールした際に高さが維持されるように、Image ViewとImage Container Viewに対して高さが同じかそれ以上(>=)になる制約を追加します。

これにより、上方向にスクロールした際は、Image View.Top == View.Topの制約をブレイクしつつ、Image ViewとImage Container Viewの高さが同じという制約は満たせるので、Image Viewは高さを維持したままスクロールに追随します。

下方向にスクロールした場合はImage View.Top == View.Topの制約とHeight >=の制約の両方が満たせるのでImage Viewは上部に固定されたままスクロールに合わせて伸び縮みするようになります。

f:id:KishikawaKatsumi:20181104180052g:plain:w320

Image Viewがナビゲーションバーの高さで固定するようにする

応用編として、先ほど紹介したテクニックを使って、上方向にスクロールした際、ナビゲーションバーと同じ高さにImage Viewが固定されて残るようにしてみましょう。

Navigation BarはViewとの直接の親子関係にないので、そのままNavigation Barと制約を付けることができません。そのため、サブビューとしてNavigation Barと同じ位置・サイズを維持するようなビューを追加します。

Navigation Barは存在する場合、その位置と大きさは、ビューの左右両端と上端、および下端がSafe Areaの上端に一致します。ビューにそのような制約を付けるとナビゲーションバーとまったく同じ位置とサイズをもつビューになります(Navigation Bar Backingとします)。


f:id:KishikawaKatsumi:20181104183143p:plain:w320

Image Viewがナビゲーションバーの位置までスクロールされてきたときに、ナビゲーションバーの下端で止まってほしいので、Navigation Bar Backingの下端に対して、ImageViewの下端を合わせる制約を追加します。これは上方向にスクロールしてナビゲーションバーの位置まできたときだけ有効になってほしいので優先度を下げます(Low (250))。これだけでは制約が足りないので、Navigation Bar BackingとImage Viewの高さを一致させるという制約を同様に優先度を下げて(Low (250))追加します。

最後に、Image ViewとImage Container Viewの高さが同じかそれ以上という制約の優先度を下げます(High (750))。

(ここまでの制約は図で示すことが難しいのでサンプルコードをみて実際に試してみてください。)

そうすると、上方向にスクロールして優先度の低い制約を満たせる状況になった際に制約が有効になり、Image Viewがナビゲーションバーの位置に固定されるようになります。 下方向にスクロールした場合は単に制約が満たせなくなり、優先度が低いため単に無視されて別の制約が満たされることになります。


f:id:KishikawaKatsumi:20181104184220g:plain:w320

まとめ

このように複数の制約が適宜ブレイクしてスイッチするように定義することで静的な制約だけでも動的な振る舞いを持たせることができます。

ここで紹介した複数の制約を優先度によって切り替えるテクニックは応用範囲が広く、使いこなせるようになると、レイアウトに関する動的な要素をかなり排除できます。

次に示す記事は、同様の内容をさらに詳しくステップbyステップで解説してくれているので、合わせて読むことをオススメします。

参考

iOSDC 2018で「堅牢なレイアウトを作るためのグッドプラクティス」というテーマで話します。

デバイス・OSバージョンの依存が少なく、メンテナンスしやすいビューを作る by Kishikawa Katsumi | プロポーザル | iOSDC Japan 2018 - fortee.jp

iOSのビューをメンテナンスし続けるのはとても大変です。

アプリケーションが提供する機能や扱う情報が複雑化するに伴って、UIも複雑になっています。

10年前とは異なり、さまざまなサイズのデバイスが使われるようになり、インタラクションの手段も増えました。 一つのアプリケーションをチームで開発することが主流になり、分担して開発する必要が出てきました。

そのような状況で、既存のコードを壊さないようにソフトウェアを継続的に改善していくということは簡単ではありません。 特に、ビューはもっとも壊れやすく、かつ壊れていることに気づくことが難しい種類のコードです。

現在私が所属しているFOLIOという会社で携わっているアプリケーションでも同じ課題を抱えています。

そこで、FOLIOのアプリケーションで実際にどのような問題・失敗があったのか、ビューはなぜ壊れやすいのか、具体的な事例を挙げながら、その問題にチームとしてどのように対処しているのか、問題に対処するための技術やツールをどう活用しているのかをお話しします。

絶対的な正解がある問題ではなく、私たちがやっていることがそのまま誰にでも応用できるわけではありませんが、技術や方法論は大いに参考にしていただけると考えています。

StoryboardとAuto Layoutは是々非々の意見があるツールですが、否定的に捉えている方にも改めて見直してみようと思っていただけるはずです。

それに先だち、私たちのチームで活用している、動くドキュメントとしてのUIコンポーネントサンプルコード集を会社のリポジトリで公開します。

github.com

そのまま使えるライブラリというわけではではありませんが、コンポーネントの分割する粒度や、@IBDesignable/@IBInspectableを活用したライブレンダリングの威力、ユニットテストの書き方など、大いに参考にできると確信しています。

日にちは最終日の9/2 13:30〜です。

デバイス・OSバージョンの依存が少なく、メンテナンスしやすいビューを作る by Kishikawa Katsumi | プロポーザル | iOSDC Japan 2018 - fortee.jp

ぜひ聴きに来てください。

iOSDCでテストしづらいコードをテストしやすくするための方法について話しました

speakerdeck.com

日本で開催されるもっとも大きなiOSに関するカンファレンスの1つであるTop | iOSDC Japan 2017に参加し、表題の内容で発表しました。

聴いてくださった方々からは好評のようでよかったです。発表資料は本題と関係のない話がちょこちょこ挟まったり、口頭の説明がないとわからないページがあり、スライドだけでは意図がよく伝わらない恐れがあるので、こちらで内容について補足します。

伝えたかったテーマは「依存が大きく複雑で、単体でテストしづらいコードを単体で動かしてテストできるようにするには」ということです。その題材として一般的に依存が複雑でテストしづらいコンポーネントであるビューを例として取り上げました。ですのでビューやUIをテストするということに絞った話ではなく、どのレイヤーに対しても複雑にいろいろな依存関係があってユニットテストが書けないという状況を改善するための基本的な考え方です。

このことをチームで定式化したりシステマチックにやるならMVVMやMVP、などのデザインパターンやフレームワークを適用するという考えに発展します。発表のあとで何人かと話したり私がいろいろなところで聞いている印象だと、MVVMやMVP、VIPERを使っているが特にテストは無い、というプロジェクトはそこそこあると思っています。

発表でも触れましたが、分割するということは最初の大きな一歩なのでそれを否定するものではありませんが、やはりVをVMやPに分けるということはテストを書けるようにするということが基本的なモチベーションであることと、テストが書けるかどうかはm本当に依存を切り離せて疎結合になっているかどうかを確かめるもっとも簡単な方法ですので、単純に分割しただけでは、何のためにそうしているのかという問いに答えるのは難しいのではないでしょうか。

発表内容について

発表では実装の一例として私がメンテナンスしているOSSの (SpreadsheetView)https://github.com/kishikawakatsumi/SpreadsheetViewというライブラリのlayout()メソッドをテストしやすい形にリファクタリングする様子を使いました。誤解しないでほしいことは、ビューを使ったのはあくまでも実装の一例であって、内容はビューだけに関係する話ではなく、一般的に複雑な依存を持つコードをテストしやすい形にするということをお話しています。実装なしで、単に「依存が大きく複雑なコードをテストするには依存を取り除く必要がある」と話してもピンとこないと思うのであくまでも実装の例として使ったということです。

テストしやすいコードとは

layout()メソッドは下記のようにSpreadsheetViewScrollViewに依存しています。layout()メソッドはSpreadsheetViewScrollViewのプロパティやメソッドを必要とするので、このメソッドを実行するにはこの2つのビューのインスタンスを用意して、正しく設定しなければならず、単体で実行することはできなくはないが、とても大変なので現実的ではないという状況です。

final class LayoutEngine {
    private let spreadsheetView: SpreadsheetView
    private let scrollView: ScrollView
    ...

    init(spreadsheetView: SpreadsheetView, scrollView: ScrollView) {
        self.spreadsheetView = spreadsheetView
        self.scrollView = scrollView

        intercellSpacing = spreadsheetView.intercellSpacing
        defaultGridStyle = spreadsheetView.gridStyle
        circularScrollingOptions = spreadsheetView.circularScrollingOptions
        ...
    }
    
    func layout() {
        guard startColumn != columnCount && startRow != rowCount else {
            return
        }
        
        let startRowIndex = spreadsheetView.findIndex(in: scrollView.rowRecords, for: visibleRect.origin.y - insets.y)
        cellOrigin.y = insets.y + scrollView.rowRecords[startRowIndex] + intercellSpacing.height
        
        for rowIndex in (startRowIndex + startRow)..<rowCount {
            ...

                scrollView.insertSubview(cell, at: 0)
        }
        ...
    }


    private func enumerateColumns(currentRow row: Int, currentRowIndex rowIndex: Int) -> Bool {
        ...
        while columnIndex < columnCount {
            ...
            scrollView.insertSubview(cell, at: 0)
        }
    }

    ...
}

つまり、単体でテスト可能にするためには依存を取り除く必要があります。

ここで依存関係には2種類あり、依存関係の状態(プロパティ)に依存している場合と、振る舞い(メソッド)に依存している場合があります。layout()メソッドは両方に依存しています。

状態の分離

状態への依存を取り除くためには状態をモデルに分離します。これは大規模であってもそれほど難しくはありません。

struct SpreadsheetViewConfiguration {
    let intercellSpacing: CGSize
    let defaultGridStyle: GridStyle
    let circularScrollingOptions: CircularScrolling.Configuration.Options
    ...
}

struct DataSourceSnapshot {
    let frozenColumns: Int
    let frozenRows: Int
    ...
}

init(spreadsheetViewConfiguration: SpreadsheetViewConfiguration,
     dataSourceSnapshot: DataSourceSnapshot,
     scrollViewConfiguration: ScrollViewConfiguration,
     scrollViewState: ScrollView.State) {
    ...
}

依存関係が持つプロパティからメソッドの実行に必要なものを抽出し、性質によって分類、3つのモデル(Struct)として分離しました。こうすることで、ビュー自体をセットアップするという複雑な手順ではなく、モデルに必要な値を設定して渡せるようになりました。

振る舞いの分離

一方、振る舞いの依存を分離するためにはモックに置き換えるという方法があります。今回はscrollView.insertSubview(cell, at: 0)というメソッドの呼び出しをモックに置き換えます。

本当のオブジェクトではなくモックを渡せるようにするには、オブジェクト自体ではなくインターフェースの依存に変更し、同じインターフェースを持つ別のオブジェクトを渡せるようにします。実装ではなくインターフェースに依存することで、別の実装を渡せるようにするということです。

scrollViewオブジェクトが持つすべてのメソッドをインターフェースに分離するのは大変で、コストが合わないかもしれないので、今回はscrollView.insertSubview(cell, at: 0)だけをモックにするというテクニックを紹介しました。

基本的なやり方は変わらず、まず共通のインターフェースを用意します。scrollViewの代わりにこのインターフェースに依存します。そうすることでこのインターフェースに適合していればscrollViewではないオブジェクトを適当に作って渡すことができます。

protocol ViewLayouter {
    mutating func layout(cell: Cell)
}

プロダクションのコードでは次のようにscrollViewを内部にもち、もともとのscrollView.insertSubview(cell, at: 0)を呼び出すオブジェクトを渡すように変更します。元の動作はまったく変わっていません。

struct Layouter: ViewLayouter {
    let scrollView: ScrollView

    func layout(cell: Cell) {
        scrollView.insertSubview(cell, at: 0)
    }
}

テストコードでは実際のビューの代わりにビューのメタデータだけを保持する別のオブジェクトを渡します。こうして依存関係を取り除き、このメソッドの実行には実際のビューを用意する必要はなくなりました。また、ビューがレイアウトされた結果をビューではなく、別のわかりやすいデータ構造で検証できるようになりました。

struct DebugLayouter: ViewLayouter {
    var cells = [CellInfo]()

    mutating func layout(cell: Cell) {
        cells.append(CellInfo(frame: cell.frame,
                              indexPath: cell.indexPath))
    }
}

まとめ

発表した内容で重要な部分をまとめました。

テストしやすいコードとは良いコードで、複雑なコードをテストしやすくするには、依存関係を分離していくことが有効です。

当たり前のことを話しているだけですが、ソフトウェア開発の複雑さに対抗する手段として、非常い広く応用できる基本的な考え方であることがおわかりいただけると思います。なんとなくMVVMやMVPを使っていたのなら、これまでよりも明確に目的を持って使えるようになると思います。

より詳しくはスライドと、後日公開される発表の録画をご覧ください。質問や批評などがありましたらいつでも連絡してください。