24/7 twenty-four seven

iOS/OS X application programing topics.

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を使っていたのなら、これまでよりも明確に目的を持って使えるようになると思います。

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

try! Swift Tokyo 2017をもっと楽しむために

いよいよ明日はtry! Swift Tokyo 2017が開催されます。 try! Swift Tokyo 2017を最大限楽しんでいただくために、ちょっとしたコツをお話しします。

公式アプリ

try! Swift公式アプリがAppStoreから配信されています。タイムテーブルやセッション概要などが掲載されていますので、事前にインストールしておきましょう。Apple Watchを持っていれば時計の文字盤に情報を表示することもできます。

try!

try!

  • Natalya Murashev
  • ソーシャルネットワーキング
  • 無料

ソースコードはこちらです。興味のある方はPRを送ってください。

github.com

github.com

公式Slackチャンネル

参加者のみなさんをtry! SwiftのSlackチャンネルにご招待しています。もし、招待メールが届いていない方は info@tryswiftconf.comにご連絡ください。Slackでは自己紹介や、質問など、自由に参加者および講演者の方とコミュニケーションをとっていただいて構いません。食事やお茶、オフィス見学に誘ってみるのも良いでしょう😉

来場はお早めに

会場はベルサール新宿セントラルパークです。付近のエリアには「ベルサール」と名のつく会場が3つありますので間違えないようにご注意ください。

できるだけ早めにお越しください。700人以上の方が来場されますので、開会直前に多くの人が来られますと、時間内に受付が終了しない恐れがあります。会場には軽食やお菓子を用意してますので、早めに着いてゆっくり準備をすることをお勧めします。

人と話そう

カンファレンス、と言っても講演の内容は後からスライドとビデオが公開される予定ですし、技術的なことは参加者のレポートなどを読むだけでもついていけると思います。ただ行って話を聞いて「ああいい話を聞いた」って帰ってしまうのはやはりもったいないと思います。

やはり、安くない参加費を払って、わざわざ会場に足を運ぶということは、その時しか得られない何かを持って帰るためだと思いますので、ぜひ積極的に人と話しましょう。 try! Swiftでは講演以外の直接スピーカーや他の参加者の方と話すことのできる時間をできるだけ多く設けています。

特にこのカンファレンスでは海外の講演者および参加者の方がたくさんいらっしゃいます。実はモバイル開発における日本は世界から非常に注目されていて、日本のことを知りたいと、みんな大変な興味を持っています。

Q&Aルーム

まず、すべてのスピーカーについて、講演後の1〜2時間はスピーカーに直接質問をすることができるQ&Aセッションの時間があります。Q&Aセッションはホールの外の控え室で行われるため、通常のセッションは聞き逃してしまいますが、うまく使えばセッションをただ聞くだけよりも有意義な時間の使い方になるでしょう。

MacBookを持っていけば、コードについて質問したり議論することもできます。WWDCやTech Talkに参加されたことがある方は、ラボのようなものだと思ってください。

そして、Q&Aセッションには通常のセッションと同様にプロの通訳がつきます!なので言葉に不安がある方でも問題ありません。 また通訳の費用はみなさんのチケット代、およびスポンサー料から賄われていますので、利用しなければ損です🤑

Q&Aセッションに来られる方が少なければ、いろいろ準備して日本に来られるスピーカーの方々も残念に思うでしょうし、私たちも準備したかいがありません。ぜひ積極的に話をしにいってみてください!

スピーカーディナー・懇親会

2日目(3/3)には参加者なら誰でも参加可能なオフィシャルパーティ(懇親会)があります。

パーティの会場はキリストンカフェ 東京です。セッションの話やSwiftの話で大いに盛り上がってください。

朝食・ランチ・コーヒータイム

意外と狙いどころは、セッションが始まる前の朝食タイムと、ランチタイム、そしてコーヒーブレイクの時間だと思います。こういった時間はどうしても知った顔同士で一緒になりがちですが、勇気を出して講演者の方や知らない参加者の方に声をかけてみましょう。

コーヒーやお弁当を片手に行けば自然と一緒に話をする流れになるでしょう。

人がいっぱいの懇親会に比べて、こういう時間に話した人はけっこう後になっても覚えているものです。たくさんコネクションを作って帰ってください。

通訳ボランティア

今回から参加者による通訳ボランティアを募ろうと考えています。ボランティアをやってくださる方は目印としてリストバンドをつけてもらいます。リストバンドをつけている方に声をかけて外国の方と話すときは手伝ってもらうことができます。

iPhoneに自分の作品を入れておく

try! Swiftに来ているのはもちろんiOS/OS Xのデベロッパーですし、Swiftが好きな人ばかりなので、話のきっかけさえつかめれば、楽しく話せると思います。

言葉やヒアリングが苦手でも、作ったものを見せればいいです。 みなさんはモバイルの開発者なので、iPhoneがあればすぐに作ったものをデモすることができます。来場の前にiPhoneに自分が作ったものを準備しておきましょう。

try! Swift Tokyo 2017を開催します

try! Swiftは世界中のSwiftデベロッパーが一堂に会し、Swiftに関する知見を共有するカンファレンスです。国内外からSwiftデベロッパーが参加する、世界最大級のコミュニティでもあります。 会期は2017年3月2日〜4日の3日間、うち2日、3日は招待講演とライトニングトーク、4日はハッカソンを行います。

TOKYO - try! Swift

今回はより多くの方に来場していただけるように広い会場を確保しました。およそ前回の1.5倍(800〜900人)の方にお越しいただけます。 すべての講演とQ&Aにはプロによる同時通訳を提供いたしますので、英語に自信がなくても問題なく楽しんでいただけます。

現在(Webサイト)https://www.tryswift.co/tokyo/jpには18名の講演者が掲載されていますが、さらに4名、合わせて22名の講演を予定しています。

たとえば、Fastlaneの作者であるFelix Krauseや2015年からNSHipsterの編集長を務めていてSwiftDoc.orgの作者でもあるNate Cook、CocoaPodsのOrta Theroxなど、誰でも知っているエンジニアが講演者として来日します。

彼らと直接コミュニケーションがとれる非常に貴重な機会です。みなさんふるってご参加ください!

コミュニケーション

前回の経験を活かし、ボランティアスタッフによる通訳など、コミュニケーションをサポートする仕組みについても、より充実したものにする予定です。

try! Swiftのおもしろいところとして、各カンファレンスにおいて参加者専用のSlackが用意されます。Slackには講演者の方々も参加しており、参加者同士のコミュニケーションに利用されます。海外から来られる方は、会期より長く滞在される方もたくさんいますので、ランチに誘ったりすると楽しいです!

自分から誘うのが恥ずかしくても、誰かが呼びかけてるのを見つけたら積極的に乗っかってみましょう。

下のスクリーンショットは前回のSlackの様子です。

f:id:KishikawaKatsumi:20161117140444p:plain:w480

f:id:KishikawaKatsumi:20161117140449p:plain:w380

f:id:KishikawaKatsumi:20161117140453p:plain:w260

f:id:KishikawaKatsumi:20161117140456p:plain:w400

参加費・チケット

チケットは公式サイトから購入できます。参加費にはランチやコーヒー、懇親会の費用も含まれています。 一般のチケットは最大でおよそ700枚ほどになりますが、前回よりも早いペースで購入していただいているので、早めのご購入をお勧めします。

ライトニングトーク

今回は招待講演に加え、参加者の皆さんによるライトニングトークを実施します。チケットを購入された方はライトニングトークのCFPに応募することができます。ライトニングトークの募集については、準備ができしだいお知らせしますので楽しみにお待ちください。

スポンサー募集

イベントの開催、およびコミュニティを支援していただくスポンサーを募集しています。来年も再来年もずっとこのコミュニティが成長していけるよう、ご支援をお願いします。 詳細については info@tryswift.co までお問い合わせください。担当者からご連絡いたします。

過去のtry! Swiftの各講演のビデオ

try! Swiftカンファレンスの各講演は録画されています。前回のtry! Swift Tokyoと9月に開催されたtry! Swiftニューヨークの講演は下記にてご覧いただけます。一部の講演については日本語訳も公開されています。

realm.io

懇親会における英会話のプロトコル

前回の記事では、カンファレンスをより楽しむために積極的に人と(特に海外の人と)話そうと書きました。しかしそうはいっても、言葉に自信がなかったりしてなかなか積極的に話しかける勇気が持てないかもしれません。

でも心配いりません。懇親会(ミートアップ)の会話はほとんど決まった形で始まるので、それを覚えておけばとりあえずなんとかなります。

挨拶と自己紹介のプロトコル

とりあえずこの手順だけ覚えておきましょう。以下の流れから外れることは90%ありません。

  1. (相手を見て)声をかける「Hi」
  2. 名前を言う。「I'm Katsumi」/「My name is〜」
  3. 「どこで働いてる/何をしている」か聞かれるので答える。「I'm iOS developer, work at Realm」/「I'm working at Realm. I develop〜」

要するに、1. 声をかけて、2. 名乗って、3. 自己紹介、これだけです。最初のうちは口がうまく回らないので、これだけでも大変かもしれませんが、何人か繰り返すうちにすぐに慣れます。これだけができるようになったらもう海外のミートアップでも大丈夫です。

ここまで済んだら後は流れでなんとかなります。がんばってください。大変だったらここで「また後でね」「Thank you. Talk to you later.」って言って去ってもいいですが、せっかくなのでもう少し話してみましょう。

伝わりやすい話のネタ

仕事や開発してるものの話をするのがやりやすいです。何をしている会社か説明が難しい場合は、海外の似たようなサービスの名前を出すのが伝わりやすいです。(〜と似たようなものだよ、と言うのは抵抗があるかもしれませんが、そこは割り切って)

うまく言葉が出てこなければ、自分が作ってるものや仕事で担当しているものを見せるのがいいです。動くものがある場合はデモができるように自分のiPhoneに準備しておきましょう。データとかも英語で、かつ本物っぽくしておく方が伝わりやすいです。

コードを公開している人は、自分のGitHubリポジトリを見せるのも良いです。何をするライブラリなのか、ある程度スラスラと言えるように準備しておきましょう。

大ざっぱにまとめると、まず話しかけましょう。もしくは目を合わせましょう。向こうから寄ってきます。人と話したくない人はそもそもカンファレンスや懇親会に出てこないので、気にすることありません。どんどん行きましょう。

そしてお互いに名乗って、自己紹介をしたら後はがんばってください。話を続けるのに困ったら、作ったものを動かして見せましょう。これはどこに行っても使えるテッパンネタです。

デモは事前にいくらでも準備ができるので言葉に自信がなくても大丈夫でしょう?

英語力や発音を気にしない

英語の能力や発音は一夜づけではどうにもなりません。諦めましょう。

しかし、不思議なことですが、こちらの発音や文法がどれだけ拙くても向こうの人にはちゃんと伝わっています。私たちは聞き取りにかなり苦労しているのになぜでしょうね。

とにかく、文法がおかしかろうが、単語を並べただけだろうが、通じるのでどんどん話しましょう。ちょっと失敗しても人はたくさんいます。何人かと話すとだんだん口が回ってきてあまりつっかえずに話すことができるようになります(ほんの十数分の間で😳)。

相手も母国語が英語でない人はいっぱいいますし、変な英語を話す人もいっぱいます。向こうは流暢な英語が出てこないことは十分わかっているので、いちいち英語が未熟なことについて最初に断る必要はありません。気にせず話しましょう。

もちろん細かいニュアンスが必要ならそれなりにきちんと話す必要がありますが、そう言った会話は日本語で日本人同士が話してもなかなか伝わらないものです。ましてや初対面で。そう考えると、細かいことを気にしてもしょうがないと思いませんか?

わかるまで何度でも聞き直そう

ヒアリングは、一朝一夕ではなんともなりません。何度聞き直しても聞こえない部分はどうしてもあります。 聞き返すのは別に失礼でもなんでもないので、わからなかったら遠慮なく何度でも聞き直しましょう。聞き直すには「Sorry?」と語尾を上げるだけでもいいですし、「Could you say that again?」と丁寧に言ってもいいです。

別に会議をしているわけではなくて1対1の会話なので、相手の言葉が聞き取れなければ気にせず何度でも聞き直しましょう。誰も鬱陶しく思ったりしません。 むしろほとんどの人は自分の発音やしゃべり方が悪いから聞き取れないんだと思っています。

特定の単語がどうしても聞き取れなくて意味がよく分からない、ということもよくあります。その時は相手の言葉を繰り返して「〜〜, what?」と分からなくなったところで「what?」と言えばそこだけはっきり言ってくれたり、別の言葉に言い変えたりしてくれます。

発音のバリエーションに気をつける

話す人によって、同じ単語の発音でも結構違います。Dataが「ダタ」になったりMakeが「マイク」、Arrayは「アライ」という人は結構います。備えてないと、単語が聞き取れなくて、全体の意味が分からなくなってしまいますが、頭の片隅にこのような発音の変化がある、ということを入れておけばけっこう聞き取れます。

私の最近の経験ではEither/Neitherを「アイザー/ナイザー」って言われて聞き取れなくて、なんども聞きなおすことがありました。

あとNSErrorとNSArrayが一緒に聞こえてよく分からなくなった、なんてこともありました。

困ったら頼ってください

もしどうしても最初に話しかけることができなかったり、言葉に困った場合は私やスタッフに声をかけてください。流暢に会話ができるわけではありませんが、できる限りお手伝いします。

try! Swift 2016を200%楽しむために

いよいよ今週はtry! Swift 2016が開催されます。

せっかくの機会ですので貴重なチケットを手に入れられた方にtry! Swift 2016を最大限楽しんでいただくために、ちょっとしたコツをお話しします。

公式アプリ

try! Swift公式アプリがAppStoreから配信されています。タイムテーブルやセッション概要などが掲載されていますので、事前にインストールしておきましょう。Apple Watchを持っていれば時計の文字盤に情報を表示することもできます。

try!

try!

  • Natalya Murashev
  • ソーシャルネットワーキング
  • 無料

公式Slackチャンネル

参加者のみなさんをtry! SwiftのSlackチャンネルにご招待しています。もし、招待メールが届いていない方は info@tryswiftconf.com にご連絡ください。Slackでは自己紹介や、質問など、自由に参加者および講演者の方とコミュニケーションをとっていただいて構いません。食事やお茶、オフィス見学に誘ってみるのも良いでしょう😉

来場はお早めに

会場は渋谷マークシティ、サイバーエージェントのセミナールーム13Fです。実際の行き方は少しわかりにくいので、こちらのスッキリわかる渋谷マークシティ入門!を参考にして、できるだけ早めにお越しください。500人近い方が来場されますので、開会直前に多くの人が来られますと、時間内に受付が終了しない可能性があります。会場にはサンドイッチやお菓子を用意してますので、早めに着いてゆっくり準備をすることをお勧めします。

人と話そう

カンファレンス、と言っても講演の内容は後からスライドとビデオが公開される予定ですし、技術的なことは参加者のレポートなどを読むだけでもついていけると思います。ただ行って話を聞いて「ああいい話を聞いた」って帰ってしまうのはやはりもったいないと思います。

やはり、安くない参加費を払って、わざわざ会場に足を運ぶということは、その時しか得られない何かを持って帰るためだと思いますので、ぜひ積極的に人と話しましょう。 try! Swiftでは講演以外の直接スピーカーや他の参加者の方と話すことのできる時間をできるだけ多く設けています。

特にこのカンファレンスでは海外の講演者および参加者の方がたくさんいらっしゃいます。実はモバイル開発における日本は世界から非常に注目されていて、日本のことを知りたいと、みんな大変な興味を持っています。

Q&Aルーム

まず、すべてのスピーカーについて、講演後の1〜2時間はスピーカーに直接質問をすることができるQ&Aセッションの時間があります。Q&Aセッションは17Fの別の部屋で行われるため、通常のセッションは聞き逃してしまいますが、うまく使えばセッションをただ聞くだけよりも有意義な時間の使い方になるでしょう。

MacBookを持っていけば、コードについて質問したり議論することもできます。WWDCやTech Talkに参加されたことがある方は、ラボのようなものだと思ってください。

そして、Q&Aセッションには通常のセッションと同様にプロの通訳がつきます!なので言葉に不安がある方でも問題ありません。 また通訳の費用はみなさんのチケット代、およびスポンサー料から賄われていますので、利用しなければ損です🤑

Q&Aセッションに来られる方が少なければ、いろいろ準備して日本に来られるスピーカーの方々も残念に思うでしょうし、私たちも準備したかいがありません。ぜひ積極的に話をしにいってみてください!

スピーカーディナー・懇親会

2日目(3/3)には招待制のスピーカーディナー(主にスポンサー企業の参加者やボランティアスタッフが招待されています。)、最終日(3/4)には参加者なら誰でも参加可能なオフィシャルのパーティ(懇親会)があります。

最終日の懇親会の会場はTOMBOY INDIAN LOUNGE DINING 渋谷106道玄坂店です。

慣れていなければ、懇親会で外国の方に英語で話しかけるのは難しいかもしれませんが、心配はいりません。懇親会における会話の手続きはだいたい決まっているので、それを覚えておけば大丈夫です。そのあたりのテクニックについては次の記事で紹介します。

それに懇親会なので相手は人と話そうと思っているし、前述のとおり、海外の方は日本の事情をぜひ知りたいと思ってきているので、普通にやってれば話は弾みます。

朝食・ランチ・コーヒータイム

意外と狙いどころは、セッションが始まる前の朝食タイムと、ランチタイム、そしてコーヒーブレイクの時間だと思います。こういった時間はどうしても知った顔同士で一緒になりがちですが、勇気を出して講演者の方や知らない参加者の方に声をかけてみましょう。

コーヒーやお弁当を片手に行けば自然と一緒に話をする流れになるでしょう。

人がいっぱいの懇親会に比べて、こういう時間に話した人はけっこう後になっても覚えているものです。たくさんコネクションを作って帰ってください。

iPhoneに自分の作品を入れておく

try! Swiftに来ているのはもちろんiOS/OS Xのデベロッパーですし、Swiftが好きな人ばかりなので、話のきっかけさえつかめれば、楽しく話せると思います。

言葉やヒアリングが苦手でも、作ったものを見せればいいです。 みなさんはモバイルの開発者なので、iPhoneがあればすぐに作ったものをデモすることができます。来場の前にiPhoneに自分が作ったものを準備しておきましょう。 私がユビレジにいたときはiPadを持って会場をウロウロしていました(ユビレジはiPadアプリしかないので😅)