24/7 twenty-four seven

iOS/OS X application programing topics.

iOSDC 2023 Mastering SwiftSyntax

先日のiOSDC 2023にて「Mastering SwiftSyntax」という発表をしました。

fortee.jp

スライド: speakerdeck.com

台本とアニメーション付きのスライド: www.icloud.com

サンプルコード: github.com

マクロではなくSwiftSyntaxの解説なのでマクロの話を詳しくしなかったことは想定通りですが、改めてプロポーザルを読んでみるとマクロという単語をたくさん使ってるので、これはちょっとマクロの話だと期待した方もいたのかなと後で反省しました。

意図としてはマクロの数パターンについてチュートリアルのような説明をしてもそのパターン以外に使えるようになるかというと難しいので、基礎的な知識をできるだけ網羅的体系的に伝えることにしました。

だいぶ詰め込むことになったのと論理構成の都合で体系的に伝えることはそこまでできなかったのですが、必要なことはすべてスライドか話した内容に含まれているので、Keynoteを発表者ノートとアニメーションを有効にして復習してみてください。

SwiftSyntaxは自動生成されたコードが大部分を占めていて、構文木だけでなくParserも含み、Parserのコードはパフォーマンスを優先するためにマニュアルのメモリ管理など読みやすいコードとはいえません。

しかし発表にある通りSwiftSyntaxを使う上ではその部分を気にすることはなく、一定のパターンに従った定型的なプログラミングになります。

少し書いてみるとすぐに慣れると思うので、マクロを作ってみたりSwiftLintのルールを修正してみたりして練習してください。

発表で使ったもの

今回スライドに使うフォントをヒラギノUDにしてみました。ちょっとみやすくなった気がします。

8BitDo Microというカードサイズのすごい小さいゲームコントローラ。かわいい。キー設定のアプリがiPad用だけどMacでも普通に動くのでこれをKeynoteのスライドが移動できるキーに設定してリモコンとして使った。台にMacを置くとちょっと低いけどこれで操作することで前を向いて喋ることができます。

www.8bitdo.com

一部の人にウケてた「9:41」のTシャツ。MacやApple製品っぽいモチーフのアパレルなどを作ってるところで、他の製品はライセンス的に怪しいけれども「9:41」のTシャツは人前で着ても問題ない。ただの時刻だから。これが何か特別な数字に見えるひとは・・・。

9:41 T-shirt Blackthrowboy.com

SwiftマクロでPowerAssertを実装する

現在Swiftにマクロを導入しようという提案がSwift Evolutionのレビュー中*1です。 SwiftによってSwiftの構文を拡張できる、いわゆるメタプログラミングと呼ばれる機能です。

実はマクロの他にもSwiftでメタプログラミングを実現する機能の提案が複数提案*2*3されています。 Swift 6はメタプログラミングの時代になるかもしれません。

現代的なプログラミング言語のマクロ

みなさんはマクロと聞いて、どのような機能を想像しますか? C言語のマクロは、プリプロセッサと呼ばれるコンパイル前のプログラムによってプログラムのソースコードに置換や文字列連結を行う機能でした。 原理的には単なる文字列操作なので、プログラムの構造や型を破壊する可能性がありました。 最初のマクロに関する投稿に対しての否定的なコメントは、C言語のマクロのような機能をSwiftに導入することは危険だという意見が多かったようです。

しかし、現代のプログラミング言語では、マクロの機能は大きく異なっています。 C++に導入されたテンプレートやRustに導入されたマクロは、プログラムの構造を操作する機能です。 たとえばRustのマクロは、プログラムのAST(抽象構文木)を操作する機能です。 ASTはプログラムの構造を抽象化した木構造で、プログラムのコンパイルや解析などに利用します。 Rustのマクロは、プログラムのASTを操作することで、プログラムの構造を変更できます。 このようなマクロは、プログラムの構造を操作する機能であるため、C言語のマクロのような危険性はありません。 また、マクロを使うことでプログラムの構造をより柔軟に操作できるようになります。

Swiftのマクロはコンパイラによる型チェックが行われるため、型安全であることが期待できます。 Swiftではマクロを導入することで、一部のDSLをより簡潔に記述できるようになったり、機械的なボイラープレートの記述をなくせたりということが利点として挙げられています。

Swiftにおけるマクロの実装

Swiftのマクロが単なる文字列操作でないことを理解するために、Swiftのマクロをどのように実装するのかをみてみましょう。

Swift Coreチームは、現在Compiler Control Statementsと呼ばれる#file#colorLiteralなどの機能をマクロで再実装することを検討しているようです。 提案書にはマクロのサンプルコードとして現在は標準の言語機能として組み込まれている#colorLiteralをマクロ機能で再実装したもの*4が公開されています。 #colorLiteralマクロは次のように使用します。

let backgroundColor: UIColor
  = #colorLiteral(red: 0.5, green: 0.5, blue: 0.25, alpha: 1.0)

もともとのCompiler Control Statementsである#colorLiteralはXcode上でカラーピッカーを用いて色を選択できたり、実際の色がソースコード上に表示され確認できるというIDEのサポートを前提とした機能で、実際にマクロとして動作するわけではありません。 今のところSwiftマクロの実装では#colorLiteralマクロは次のようなソースコードの形に展開するように実装しています。

let backgroundColor: UIColor
  = .init(_colorLiteralRed: 0.5, green: 0.5, blue: 0.25, alpha: 1.0)

では、このような動作を実現する#colorLiteralマクロの実装をみてみましょう。

// Declaration of #colorLiteral
@expression macro colorLiteral(
  red: Float, green: Float, blue: Float, alpha: Float
) -> _ColorLiteralType
  = SwiftBuiltinMacros.ColorLiteralMacro

// Implementation of #colorLiteral
struct ColorLiteralMacro: ExpressionMacro {
  /// Replace the label of the first element in the tuple with the given
  /// new label.
  func replaceFirstLabel(
    of tuple: TupleExprElementListSyntax, with newLabel: String
  ) -> TupleExprElementListSyntax{
    guard let firstElement = tuple.first else {
      return tuple
    }

    return tuple.replacing(
      childAt: 0, with: firstElement.withLabel(.identifier(newLabel)))
  }

  static func expansion(
    of node: MacroExpansionExprSyntax, in context: MacroExpansionContext
  ) -> MacroResult<ExprSyntax> {
    let argList = replaceFirstLabel(
      of: node.argumentList, with: "_colorLiteralRed"
    )
    let initSyntax: ExprSyntax = ".init(\(argList))"
    if let leadingTrivia = node.leadingTrivia {
      return MacroResult(initSyntax.withLeadingTrivia(leadingTrivia))
    }
    return initSyntax
  }  
}

まず次にに示すようにマクロの型宣言があります。

@expression macro colorLiteral(
  red: Float, green: Float, blue: Float, alpha: Float
) -> _ColorLiteralType
  = SwiftBuiltinMacros.ColorLiteralMacro

このコードは#colorLiteralというマクロはFloatの引数を3つ受け取り、戻り値として_ColorLiteralTypeを返すというインターフェースであることを定義しています。 文法は関数の定義とほとんど同様です。関数を示すfuncの代わりに@expressionmacroという2つのキーワードを使用します。 マクロであることを示すだけなら@expressionは不要ですが、これは将来マクロの種類が増えた場合に各マクロを区別するために用意されています。

マクロの可能性を探る

ここまででSwiftのマクロがどのようなものか、そしてどのように実装されているかを解説してきました。 単なる文字列操作ではなく、プログラムの構造を操作する機能であることがわかりました。

前述のサンプルコードは型チェックが働くものの、結果的に単なる文字列操作と変わらないと感じるかもしれません。 そこで少し高度で、かつ実践的な例をひとつ考えてみましょう。

マクロを使ってPower Assertを実装する

Power Assertは、プログラムのテストが失敗したときにテスト条件の評価結果を分かりやすく表示する機能です。 たとえば次のようなテストコードがあったとします。

func testPowerAssert() {
  let a = 1
  let b = 2
  let c = 3
  #powerAssert(max(a, b) == c)
}

このテストコードはabのうち大きい方がcと等しいことをテストしています。 このテストが失敗したときにPower Assertは次のようにテスト条件の評価結果をアスキーアートを使ってわかりやすく表示します。

#powerAssert(max(a, b) == c)
             |   |  |  |  |
             7   4  7  |  12
                       false

Swiftのマクロは構文木を受け取って構文木を返す機能です。 ソースコードの変数や式といった構造を受け取れるのでPower Assertの機能を実装するために必要な引数のひとつひとつの情報をマクロの内部で扱えます。

具体的には#powerAssertマクロに渡された引数の各要素を評価して、引数の位置とともに保存するコードを生成します。 マクロに渡された式全体がfalseのときにテストが失敗したとして、それぞれの変数や式の値をアスキーアートで表示するコードを生成します。

最初のテストコードに含まれるマクロ#powerAssert(max(a, b) == c)は、マクロによって実際は次のようなコードに変換されます。

PowerAssert.Assertion(#"#powerAssert(max(a, b) == c)"#, line: 14)
  .assert(max(a, b) == c)
  .capture(expression: max(a, b), column: 13)
  .capture(expression: a.self, column: 17)
  .capture(expression: b.self, column: 20)
  .capture(expression: max(a, b) == c, column: 23)
  .capture(expression: c.self, column: 26
  .render()

このコードを実行すると前述のアスキーアートを表示する結果が得られます。 これはコンパイル時にソースコードの構造に対して変更を適用できるというSwiftのマクロならではの活用例です。単純な文字列操作では、Swiftの構文解析器を実装するなどしなければ、このような機能は実現できません。

さらに本Power Assertの実装は、後に提出されたSwift Evolutionのプロポーザル*5にマクロのユースケースのひとつとして紹介*6されています。

このSwift Power Assertの実装はGitHubリポジトリ*7で公開しています。興味があったら参考にしてください。

参考資料

DeepLの非公式Safari機能拡張(Mac・iPhone・iPad)

公式に提供されているChrome機能拡張とだいたい同じような使い勝手になっています。

ソースコードはこちら

github.com

機能概要

ページ全体の翻訳(Proユーザーのみ)

選択したテキストを翻訳(誰でも)

その他のスクリーンショット

📱 iOS app

 iPad app

💻 Mac app

iOSDC 2022「アニメーションAPIのすべて」補足など

先日のiOSDC 2022にて「アニメーションAPIのすべて」という発表をしました。

fortee.jp

きっかけはDroidKaigi 2021で荒木佑一さんの「動かす」という発表です。

www.youtube.com

Androidのさまざまなアニメーション APIについてコードや具体的な例を用いて解説する内容です。最後にスライド自体がAndroidアプリとして作られていて、サンプルのアニメーションはすべて実際に動いていたものだった、と明かされるところが非常におもしろいと思ったのです。ぜひこれのiOS版をやろうとそのとき考えたのでした。

ちなみに、荒木さんはそれ以前のDroidKaigiや別のカンファレンスでも「動かす」シリーズで話されているので資料などを探して読んでみるとどれもおもしろいです。

ということで1年間あたためていたアイデアが無事採択されたことはよかったのですが、さすがにこの内容を40分にまとめるのは簡単ではありませんでした。最初は漠然とAPIを一つずつ例と一緒に使い方を説明して、できるだけ高レベルなAPIを使いましょう、でいいかと思ってましたが、全然時間が足りないしたぶん例だけ並べても使い分けを判断できるようには伝わないなと思って半分はアニメーション自体の仕組みの説明に割くことにしました。

ただ、そうすると当然個々のAPIの具体的な説明はさらに減らすことになるので、なんとか40分のプレゼンテーションの形にまとめたものの、それぞれの説明を完全にはできなかったなと思ってこの記事で少し補足します。

APIの使い分けについて

次のようにUIViewはCALayerのラッパー、UIView.animate()系のメソッドはCore AnimationのアニメーションAPIのラッパー、UIViewPropertyAnimatorはさらにUIView.animate()系のラッパー、ということで基本的には高レベルのAPIを使えばいいです。ただUIViewPropertyAnimatorUIView.animate()を比べるとコードのシンプルさが全然違うので、UIView.animate()で十分書ける場合はそちらを使う方がいいでしょう。

Core Animationのアニメーションはなぜ効率的で高速なのか

Core Animationとは、のくだりでCore Animationは画面表示のための合成エンジン、ということを言ったのですが、それで済ませてしまってなぜiOSにCore Animationが導入され名前にもあるとおりアニメーションをスムーズに効率よく実行できるのかという説明は省いたのでここで補足します。 この説明に入るとCore Animationのレンダリングシステムについての話をずっとすることになり、時間がなくなるので仕方なく省きました。 もし詳しく聞きたいということがあれば、企画してもらえれば詳しく話すことも可能です、たぶん。

簡単にいうと、下のようにCALayerを使わない場合は描画をすべてビューが担当するので、ビューをアニメーションさせた場合に毎フレームDrawコールが呼ばれる、というような挙動になります。これはMacでCALayerを使わないNSViewを使って実験してみるとわかります。iOSは発表中に説明した通り、CALayerは必ず作られるのでCALayerを使わない画面表示ということは不可能です。 いっぽう、MacはCore Animationを使うかどうかは選択できるのでCALayerを使わないNSViewが使えます。その場合、例えばビューに背景色を設定するというだけのことでもdrawRect()メソッドをオーバーライドして背景色でビューの矩形を塗る、という処理を書く必要があります。 ビューをアニメーションさせた場合、毎フレームdrawRect()で背景色を塗る、という処理が呼ばれますが、Core Animationを使う場合、CALayerが一度表示内容を計算すれば(サブビューがあれば重なりも含めて)表示内容をビットマップとしてキャッシュします。 アニメーションさせる場合はCore Animationアニメーションパラメータから中間の値を導出してひとまとまりのパッケージとしてRender Serverに転送してあとはGPUが処理します。

という仕組みなのであらゆる画面表示やアニメーションはCore AnimationとRender Serverによってハードウェアを効率的に使って実行できるのです。

アニメーションの途中の状態を取得する

Core Animationのアニメーションは基本的にあるプロパティの値abに変わるその間の各値をアニメーションのパラメータに従ってなめらかに見えるように自動的に補完する、というものです。

アニメーション対象のプロパティの値は、アニメーションが発動しているときにはすでにアニメーション終了後の値に変わっているので、途中の値を観測することはできません。

ただし、CALayerにはpresentationLayerというプロパティがあり、このオブジェクト(CALayerのインスタンス)のプロパティを調べるとアニメーション中の現在の値がわかります。

通常のアプリケーションで利用することはほとんどなく、アニメーションAPIというよりはCore Animationのレンダリングの仕組みの話なので省きましたが、Discordで質問があったのでここで補足します。

Presentation TreeとRender Tree

この話をするとUIViewのツリー構造とCALayerのツリー構造に加えてさらにCALayerにはPresentation TreeとRender Treeというツリー構造があるという話になって完全にCore Animationのレダリングシステムの解説をすることになるので省いたのでした。

私たちが操作しているCALayer(あるいはUIViewを通じて)は実際にはモデルを変更していて、Core AnimationはそこからPresentation Treeという実際に画面に表示されるべき状態とRender Treeという画面に表示されるテクスチャそのものの構造を導出します。

Core Animationは最終的にRender TreeをRender Serverに転送して、実際の画面表示はRender Serverによって行われます。この仕組みにより、プロセスをまたぐホーム画面のAppスイッチャーのアニメーションなども非常になめらかに処理できます。

Render Treeは完全にプライベートで私たちが触れることはできませんが、Presentation Treeは先述のpresentationLayerプロパティを使って確認できます。

CALayerはタッチイベントを受け取りませんが、hitTest()メソッドがあるので、presentationLayerhitTest()frameの値から現在アニメーション中のレイヤーにタッチしたかどうか、なども判定できます。

まあ必要とすることはほとんどないのですが、稀にそういうことがしたい場合に、このような仕組みを知っておくと役に立ちます。

Specialized Layers

CALayerのサブクラス群は非常に有用なのでもっと時間をとって紹介したかったところです。非常に数多くのサブクラスが存在しますが、紹介できなかった中でも有用だと思うものがこちらです。

  • CATiledLayer

    • マップ.appのようなズームレベルによって異なる画像を表示することができるレイヤーです。非常に大きな画像を一部だけレンダリングしつつズームすると高精細な画像に切り替えるという挙動が簡単に実装できます。名前の通り、表示エリアをタイル状に分割してそれぞれの読み込みは非同期で行われるのでUIは非常になめらかに動きます。
  • AVSynchronizedLayer

    • AVFoundationやCore Videoにはビデオやサンプルバッファを表示するためのレイヤーのサブクラスがあります。これはAVPlayerAVPlayerLayerでビデオを再生している場合に再生時刻とCALayerのアニメーションを同期できるレイヤーです。ビデオの再生中にフェードなどのトランジションを追加する、といったことができます。

余談ですが、AVFoundationとCore Animationは関係が深く、AVVideoCompositionCoreAnimationToolというAPIを使うとCore Animationによるアニメーションをビデオに合成でき、特殊効果をつける、といったことが簡単にできます。

スライドアプリの構造

プレゼンテーションをiOSアプリを使って行うというのはそもそもの目的でした。

スライドの内容とアプリの設計を同時に行うことは不確定要素が多いのでSwiftUIではなく慣れているクラシックなUIKitアプリとして作られています。

普段はKeynoteを使ってスライドを作っているので、まずKeynoteに用意されているテンプレートのうち普段使っているものを模倣することにしました。

このようにStoryboardで同じフォントとレイアウトを作ってテンプレートとして利用できるようにします。

基本はテンプレートのどれかのクラスに、titleプロパティやsubtilteプロパティを設定すればいいのですが、大事なことはサンプルがスライドに組み込まれて実際に動いているということなので、それぞれのテンプレートにはconentViewのようなプロパティを作り、自由にビューを追加できるようにしました。

サンプルをどう表示するかという点について、最初はシンプルに直接ビューに追加することを試しましたが、そうしてしまうとスライドが表示される前にアニメーションが終了してしまったり、スライドに戻ってきた場合にアニメーションが再開しなかったりとアニメーションの管理に問題がありました。

最終的にそれぞれのサンプルをView Controllerにして、Child View Controllerとして表示することで、スライドのページ送りでviewWillAppaer/disappear()などのライフサイクルのメソッドが呼ばれるので、そこでアニメーションを作成したり削除することで表示されるときに必ず最初からアニメーションが再生できるようになりました。

複数のデバイスサイズに対応する

最初は手軽に見てもらえるようにiPhone環境に合わせて作っていましたが、さすがに小さすぎるということがすぐにわかったので、大きなデバイスを使うことにしました。 iPadは良さそうだったのですが、現代のiPadは16:9のサイズのデバイスがなかったので実際のプレゼンテーションはウインドウサイズが自由にできるCatalystでMacアプリとしてやることにしました。

ただ、実際にそのまま動かせる、ということが売りなので、iPhoneやiPadでちゃんと動くということは必要でした。

最初はAuto Layoutでなんとかなるかと思いましたが、さすがにサイズが違いすぎるし、Auto Layoutはデバイスに合わせて全体的にスケールする、というような表現にはあまり向いてないのでやめました。

コードを見るとわかりますが、結局ウインドウ自体をデバイスの画面サイズに合わせて縮小し、中身は縮小した比率にスケールするという方法で各デバイスで同じ見た目になるようにしています。

#if !targetEnvironment(macCatalyst)
    let bounds = window.bounds

    let width: CGFloat = 1920
    let height: CGFloat = 1080

    window.frame = CGRect(x: 0, y: 0, width: width, height: height)

    if bounds.width < bounds.height {
      let scale = bounds.height / height
      window.transform = CGAffineTransform(scaleX: scale, y: scale)
    } else {
      let scale = bounds.width / width
      window.transform = CGAffineTransform(scaleX: scale, y: scale)
    }

    if window.frame.width > bounds.width {
      let scale = bounds.width / window.frame.width
      window.transform = window.transform.scaledBy(x: scale, y: scale)
    }
    if window.frame.height > bounds.height {
      let scale = bounds.height / window.frame.height
      window.transform = window.transform.scaledBy(x: scale, y: scale)
    }

    window.frame.origin.x = (bounds.width - window.frame.width) / 2
    window.frame.origin.y = (bounds.height - window.frame.height) / 2
#endif

コードハイライト

スライドに載せたコードがそのまま動いている、ような表現にしたかったのでコードはそこそこたくさん載せています。 コードがハイライトされていないととても見にくいのでコードハイライトはなんとかして実現したかったことです。

最初は画像にすることを考えましたが、画像は画像でサイズをうまく合わせるのが難しく、細かく調整していると時間が足りなくなりそうだったのでなんとかしてテキストでやる必要がありました。

Xcodeのテキストエディタはコードハイライトを保ったままコピー&ペーストできるのでなんとかそれをうまく利用できないかと考えました。 問題は背景色までコピーされてしまって、白背景以外だと白の背景色が奇妙に見えてしまうということだけでした。

Keynoteの場合はペーストした後に背景色を透明にすればいいのですが、XcodeのInterface Builderで同じ操作をすると、色の設定も無くなってしまう、ということが問題でした。

ただ、何回かやっているうちに偶然、色の設定も無くなってしまったあとにUndoして再度同じ操作をしてさらにUndo、Redoとするとその過程でうまいこと色の設定が復活するという挙動が判明したので解決しました。

サンプルコードが載っているStoryboardやXIBファイルを見るとわかりますが、UITextViewにAttributed Stringでそのままコードハイライトされたテキストをペーストして使っています。

以上です。 プレゼンテーションに使ったスライドのアプリはこちらです。サンプルのアニメーションもそのまま動きます。台本の原稿も入っています。

github.com

参考になれば幸いです。アニメーションを使いこなして使って楽しいアプリを作りましょう。

macOS MontereyでXcode 14を起動する、またはmacOS VenturaでXcode 13以下を起動する。

未サポートのOSでバージョンが古すぎたり新しすぎで起動できないXcodeを起動するには、Terminal.appで

$ /Applications/Xcode-beta.app/Contents/MacOS/Xcode

のようにパッケージの中の実行ファイルを直接実行すると起動できる。

または、アプリケーションアイコンを右クリックしてShow Package Contentsを選んで、MacOS、Contentsとフォルダを開いてそこにあるXcodeの実行ファイルをダブルクリックでもOK。GUIでやりたい場合はこっち。

この方法で起動した場合、しばらく放置していると勝手に終了してしまったりするので、いつの間にか終了していた場合はやり直す。

stackoverflow.com

賃貸マンションでGoogle Nest Doorbellを使ってみた

スマートドアベル「Google Nest Doorbell (Battery Type)」を買って4か月ほど使ったので感想を書きます。

store.google.com

要約

  • 簡単に試したり現状回復を優先するなら設置は両面テープで
  • 既存のドアホンがカメラ付きならドアホンとしての性能を超えることはなく、利点はスマートフォン等で手元で応答できるという一点だけなのでそこが魅力と感じるならアリ
  • 充電しながらの使用はできない。電源の配線をしない場合は定期的に取り外しての充電が必要。

設置

キレイに設置するなら既存のドアホンを外してマウントをネジ止めして、、、とすると良さそうですがそれなりに面倒なのと原状回復のために取り外したものを保管しておくのも大変なので、設置は両面テープでやることにしました。

ただ、単に既存のドアホンの隣に付けてしまうと間違いなく既存のドアホンの方が使われてしまうので既存のドアホンを覆うように隠せてその上に設置できるようなマウントを作りました。

www.thingiverse.com

f:id:KishikawaKatsumi:20211224043346p:plain:w500

実際に設置した様子は下記になります。既存のドアホンの4辺にちょっと厚みがある両面テープで貼り付けています。3Mの両面テープは最強なので脱脂さえしっかりすれば落ちたりすることはありません。屋外に設置する場合は耐候性の高いものを選ぶと良いと思います。

それでもこれがドアホンだとはわかりにくく、特にボタンがわかりにくいので「PUSH」と書いたシールを作ってボタンに貼っています。

f:id:KishikawaKatsumi:20211224041300j:plain:w500 f:id:KishikawaKatsumi:20211224042227j:plain:w500

amzn.to

Google Homeのセットアップ

Nest Doorbellの操作はGoogle Homeアプリから行います。iPhoneやAndroidにGoogle Homeアプリをインストールしてセットアップします。

Nest Doorbellは家のWiFiに参加します。

そのためWiFiの電波が玄関ドアの外からそれなりの強度で受信できる必要があります。

5GHz帯の電波は障害物、特に金属のドアなどに弱いのでWiFiルーターの設置場所が玄関より遠いとうまく接続できない恐れがあります。

私の家はスマートロックのSesameをWiFiで操作しているのでそのために玄関ドアのすぐ近くにアクセスポイントを設置してあり、電波に関しては問題ありません。

完了すると、Google Homeアプリからカメラを確認したり会話ができます。ドアホンのボタンが押されたときはプッシュ通知が届きます。

設定によってカメラが人や荷物を認識したときにもプッシュ通知を受けることができます。

ここまでで手元でドアホンに応対するという目的は達成できますが、iPhoneは常に手元にあるものの、マナーモードで気づかなかったりすることもあるので、固定の場所で応答できるものがあった方が便利なので合わせてGoogle Nest Hubも導入しました。

store.google.com

Google Nest Hubだとドアホンのボタンが押されたときは自動的に画面が切り替わってカメラを確認したりそのまま応答できます。

これを机の手の届くところに置いておくことで簡単に席を立つことなくドアホンに応答できるようになりました。

また、ドアホンの応答については会話することもできますが、「クイック応答」というあらかじめ3種類の自動音声による応答が登録されていて、それを選ぶだけで決まった音声を再生してくれます。

f:id:KishikawaKatsumi:20211224082300p:plain:w400

ほとんどすべての来客は宅配便なので(それ以外のアポなしの来客はないし、あっても応じる必要はない)基本的に「荷物は置いていってください」を選択するだけでOKです。

ビデオ会議中にドアホンが鳴っても机の上のNest Hubからサッとクイック応答を選択するだけ、という感じで非常に快適に使えています。

クイック応答はiPhoneやNest HubだけでなくApple Watchでも使えるので、ちょっと外にいるときに荷物が届いた、という場合でもApple Watchでクイック応答を返すこともできて便利です。

カメラの応答性について

カメラの応答性はそんなに良くないです。

ドアホンがなってからカメラの映像が確認できるまで数十秒ほどかかることもあります。

なのでカメラの映像を確認しようとしている間に2回目のボタンが押されたり、不在と思われることもあります。

そのため私はカメラを確認することは結構あきらめて、単にクイック応答で投機的に「荷物は置いていってください」を返すようにしています。

クール便とかじゃなければだいたいそれで問題ないのでそのように使っています。

APIについて

DoobellをはじめNest製品はAPIが用意されています。

developers.google.com

APIを利用してカメラの映像をWebRTCで受信したりボタンが押されたなどのイベントの通知を受けることができます。

Googleがそのまま使えるサンプルアプリを公開しているので試したい場合はまずここから触ってみるのがいいでしょう。

github.com

このサンプルアプリはFirebaseにデプロイされているので自分でデプロイしなくてもAPIの利用登録とOAuthの設定などが済んでいれば、その情報を入力してそのまま使ってみることができます。

残念ながらAPIはDoorbellに関してはそれほど強力なものではなく、カメラの映像を見ることとドアベルが押された、などのいくつかのイベントのタイミングが取れるというくらいであまり良い利用方法は思いつきませんでした。

イベントに対してクイック応答が返せる、などができるとさらに自動化が進んでおもしろいと思ったのですが、そういうことはできませんでした。

期待したものとはちょっと違いましたがせっかく使い方を勉強したので電力や湿度など家のいろいろな数値と合わせてダッシュボードのように表示するWebアプリを作ってみました。

f:id:KishikawaKatsumi:20211224084642p:plain:w500

https://kishikawakatsumi-home-monitor.netlify.app/

まとめ

Nest Doorbell、非常に簡単に使えていろいろ遊べるので悪くないかと思います。

ただ、ドアホンとしては既存のドアホンよりめっちゃ優れているということはないのであくまでもスマートフォンなどと連携ができるという点を評価できない場合は難しいかと思います。

CIによるXcodeのテスト実行結果をGitHub Checksにわかりやすく表示する

Xcodeによるユニットテストの実行結果をCIサービスの画面で確認するのはなかなか大変です。

GitHubにはCIのステータスをそこそこリッチな画面表示として返せて、Pull Requestの画面から1クリックでアクセスできるGitHub Checksがあるのでそこで確認できればとても便利です。

f:id:KishikawaKatsumi:20211118115025p:plain:w400

ということでXcodeのテスト結果をGitHub Checksに表示するGitHub ActionとBitrise Stepを作りました。

github.com

Xcodeがテストを実行した際に生成するXcode Result Bundleというテスト結果やログ、コードカバレッジ、スクリーンショットなどをすべてまとめたデータを解析して、Markdownの形式でまとめてGitHub Checks APIにPOSTする、ということで実現しています。

次のように、テストを実行する際にXcode Result Bundleの出力先を指定して、このGitHub Actionにそのパスを渡すとGitHub Checksに結果が表示されます。

jobs:
  test:
    runs-on: macos-11
    name: Test
    steps:
      - uses: actions/checkout@v2
      - name: Test
        run: |
          xcodebuild -scheme MyFramework -resultBundlePath TestResults test

      - uses: kishikawakatsumi/xcresulttool@v1
        with:
          path: TestResults.xcresult
        if: success() || failure()
        # ^ This is important because the action will be run
        # even if the test fails in the previous step.

実行結果は次のようになります。サマリで失敗したテストがあるかどうかをサッと確認して、失敗してテストがあればページ内リンクで詳細にすぐに移動できるようになっています。 実際の実行結果はこちらのリンクから確認できます

UIテストなどでスクリーンショットをXCAttachmentsとして保存していた場合は画像が表示されます。便利です。

コードカバレッジを計測していた場合は、サマリの部分に表示されます。

Screen Shot

Screen Shot

Screen Shot

Screen Shot

Screen Shot

Screen Shot

CIサービスとしてBitriseを使っている場合はカスタムステップとしてXcode Result Bundle to GitHub Checksが使えます。

この場合はGitHub ChecksのAPIを利用するためにXcode Result to CheckというGitHub Appsをリポジトリにインストールする必要があります。

f:id:KishikawaKatsumi:20211118120856p:plain

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: " ")
    }
}