higan96技術メモ

https://github.com/higan96

AIコーディングエージェントでiOSアプリを11日でリリースした技術スタック

前回の記事(リンク)では、AIでアプリをリリースした体験と気持ちについて書いた。今回はその技術的な補足として、実際にどういう構成で作ったのか、何が起きたのかを書く。

作ったもの

競馬の発走通知アプリ「まもなく発走」。指定したレースの発走時刻にAlarmKitでアラームを鳴らす。開発にはClaude Codeを使った。

技術スタック

v1.0.0時点の構成はこうなっている。

  • iOSアプリ: SwiftUI + SwiftData + AlarmKit
  • データ配信: AWS S3 + CloudFront(静的JSON)
  • インフラ: Terraform(AWS)
  • CI: Xcode Cloud

APIサーバーはない。レースデータのJSONをS3に置いて、CloudFront経由でiOSアプリが取得するだけのサーバーレス構成。通知のスケジューリングもすべてiOS側でローカルに行っている。

11日間で何が起きたか

209コミット、11日間の記録。

Day1: init → AlarmKit + Live Activityが動くまで 初コミットからAlarmKitでアラームが鳴り、Live Activityでカウントダウンが表示されるところまで。20コミット。初日にアプリの核となる機能が動いたのはAIエージェントの力を実感した瞬間だった。

Day2-3: アーキテクチャ整理 機能が動いた勢いのまま手動テストで進めていたが、このあたりで無理が出てくる。Claude Codeに任せるままに書いたコードがSwiftUIとSwiftDataの密結合を生んでおり、変更のたびに手で確認する範囲が広がっていた。Service/Repositoryレイヤーの分離を開始。61コミット。現在はTDDの採用やハーネスを用意するスタイルに落ち着いているが、当時はそこまでの知見がなかった。

Day4-5: インフラ構築 Terraformを書いてAWS環境を構築。S3 + CloudFrontの静的配信構成。CloudFrontからレースデータを取得できるようになり、テストデータからの脱却。26コミット。

Day6-8: UIブラッシュアップとLive Activity封印の伏線 SwiftDataのEntityとViewの分離(値型の導入)、UIの磨き込み。Live Activityのローカル更新が不安定だという問題が無視できなくなってくる。68コミット。

Day9-11: Live Activity封印、ストア申請、リリース Live Activityの機能をPhase 2まで無効化する判断を下す。プライバシーポリシーの整備、バンドルID修正、Xcode Cloudの設定など、ストア申請に必要な周辺作業。34コミット。

ハマったこと

Live Activityのローカル更新がずれる

一番テンションが上がった機能であり、一番悔しかった機能でもある。

Live Activityでレースまでの残り時間をカウントダウン表示する機能を作った。開発中に実機で動かしたときは「これは便利だ」と素直に感動した。

ところが、ローカルでのタイマー更新が不安定だった。バックグラウンドでの更新タイミングがOSに左右されるため、カウントダウンがずれる。レースの発走時刻を過ぎてもカウントダウンが止まらず、-00:20のような表示になる。通知アプリでこれは致命的だ。

結局、v1.0.0ではLive Activity機能を丸ごと封印してリリースした。この問題はサーバーからAPNsのリモートプッシュでLive Activityを更新する方式でないと根本的に解決できない。現在Phase 2としてサーバーサイドを開発しているのは、主にこの機能を実現するためだ。

Claude Codeに任せたらSwiftUIとSwiftDataが密結合した

これはAIエージェントとの開発ならではの教訓だと思う。

Claude Codeに最初の実装を任せたところ、SwiftDataの@QueryをView内で直接使う構成になった。Apple公式のサンプルコードがこのパターンを推奨しているし、AIとしては妥当な選択だったのだろう。

問題は、このパターンだとViewがSwiftDataのEntityに直接依存し、テストが書きづらくなることだ。@QueryはSwiftUIの環境に依存するため、ビジネスロジックがViewの中に閉じ込められてしまい、ロジック単体でのユニットテストが書けない。

11日間の中でService/Repositoryレイヤーの分離やEntityから値型への変換など色々こねくり回したが、結局しっくりくる構成にはならなかった。MVVM + Clean Architecture風の構成に書き換えたのはv1.0.0のリリース後のことだ。

SwiftUIの文脈では「ViewModelは不要」という意見をよく見かける。@Queryで直接データを取れるのだからViewModelは余計なレイヤーだ、という主張だ。実際にその構成で作ってみた感想としては、嘘だと思う。少なくとも、テストを書きたいなら。(それかViewModelが同じものを指していない)

AIエージェントとの開発で感じたこと

速い。圧倒的に速い。 初日にAlarmKitとLive Activityが動いたのは、自力では考えられないスピードだった。未経験の技術領域(AlarmKit、Terraform、CloudFront)に躊躇なく踏み込めるのは大きい。

ただし、長期的な設計は人間が導く必要がある。 @Query密結合の件でいえば、早く動くものを作るという点ではAIの判断は妥当だった。問題はその先で、プロダクトの長期的な展望に基づいてアーキテクチャを方向づけるのは人間の仕事だ。AIは指示すればそれに沿ったコードを書けるが、どこへ向かうべきかは自分で決めなければならない。動くものが出てくるスピードが速いぶん、立ち止まって方向を確認する意識が重要になる。

11日間の内訳を振り返ると、「作る」より「直す」の方が多かった気がする。 AIが高速に作ったものを、人間がレビューして、構造を直して、判断を下す。そのサイクルの繰り返しだった。

AIでアプリをリリースした話と、プログラマの行き先について

アプリをリリースした

競馬の発走通知アプリを作って、App Storeにリリースした。

まもなく発走

まもなく発走

  • Norihiko Oba
  • スポーツ
  • 無料
apps.apple.com

アプリ自体は以前にも出したことがあるけど、AIのコーディングエージェントを使って本格的に作ったのは初めてだった。個人で使うちょっとしたPythonスクリプト程度なら書かせていたけど、ストアに出すアプリを一式作るのは別次元の話だ。iOSアプリ本体に加えて、レースデータを配信するためのAWSインフラまで含めて一式。初コミットからリリースまで11日間。もちろんAIの力を借りて。

なぜこんなことができたのか

Claude Codeを使った。Opus 4.5あたりから「何かが変わった」という話を見聞きしていたけれど、実際に使ってみてその感覚はよくわかった。自分のやりたいことを伝えれば、かなりの精度でコードが出てくる。

ただ、すべてがAIの進化のおかげかというと、そうとも言い切れない気がしている。自分自身のAIへの指示の出し方が上手くなった部分もあるだろうし、Web上にノウハウや事例が揃ってきたことも大きい。どこまでがAIの性能向上で、どこまでが周辺環境の成熟なのかは、正直なところよくわからない。

ただ、結果として11日でアプリがリリースできたという事実だけが残っている。

作れてしまうことへの不安

こんなに簡単にアプリがリリースできてしまうなら、プログラマはこの先どうやって生きていけばいいのだろう。

ニッチな領域で個人プロダクトを作ったとしても、同じようにAIを使えば簡単にパクれてしまう。「アプリを作れること」「プログラミングができること」の価値は、薄れつつあるのかもしれない。

手垢のついた意見だとは自分でも思う。でも、実際に11日でアプリをリリースした当事者として言うと、頭で理解するのと体験するのとでは重みがまるで違う。

それでもソフトウェアを作る喜びは変わらない

自分が欲しいものが動く瞬間

開発中に一番テンションが上がったのは、Live Activityでレースまでの残り時間がロック画面に表示されたときだった。一利用者として純粋に「これは便利だ」と思った。

ただ、このときの感情は少し複雑だった。便利さへの感動と、それがこんなに簡単にできてしまったことへのショックが同居していた。

便利だと信じているから続けている

結局このLive Activityはローカル更新の仕組みが不安定すぎて、v1.0.0では機能を封印してリリースした。作り込んだのに出せなかったのは悔しかった。

でもこの機能は絶対に便利だという確信がある。サーバーからリモートプッシュで更新する方式なら安定するはずで、今はそのためにPhase 2としてサーバーサイドの開発を進めている。v1.0.0で終わりにせず開発を続けているのは、純粋に「この機能を使ってもらいたい」という気持ちがあるからだ。

「自分のコード」から「自分のプロダクト」へ

自分のコードだという感覚は、正直なところかなり薄くなった。

以前は、お酒を飲みながら自分が書いたコードを眺めて「ここの書き方いいな」とか「これ書いてるとき楽しかったな」と悦に入ることがあった。そういう楽しみ方は、もうなくなるかもしれない。

でも、同じような喜びは「自分が作ったプロダクト」に対して感じられるのではないか、とも思っている。コードの一行一行ではなく、自分が考えたものが形になって動いているという事実に対して。愛着のスコープが、コードからプロダクトへ、一段上流に上がるだけなのかもしれない。

AIがなかったら作ろうとすら思わなかった

もしAIがなかったら、このアプリを作ろうと思っていただろうか。たぶん思っていない。仮に思いついたとしても、作りきれなかっただろう。

逆に言えば、作れるようになったことで「作りたい」が増えた。お酒を飲んでYouTubeを見る代わりに、アプリを作る夜が増えた。そのこと自体は、悪くないと思っている。

そのPresenter、もしかしてViewModelかも?

iOSアプリのアーキテクチャ整理をしていると、以下のようなコードを見かけることがあります。

import Combine

class LoginPresenter: ObservableObject {
    @Published var username: String = ""
    @Published var password: String = ""
    @Published var errorMessage: String = ""
    @Published var isLoading: Bool = false

    func login() {
        isLoading = true
        DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
            self.isLoading = false
            if self.username == "user" && self.password == "pass" {
                self.errorMessage = ""
            } else {
                self.errorMessage = "ログインに失敗しました"
            }
        }
    }
}

名前はPresenter。しかし状態を持ち、@Publishedを介してViewと双方向にバインドされています。これ、実質ViewModelでは?と思ったことはありませんか?

本稿では、「名前と責務の不一致」問題を切り口に、PresenterとViewModelの違いや選定の指針を整理します。


問題提起:名前と責務のズレ

  • 名前がPresenterだが状態を保持しViewとバインディング → ViewModel的
  • 名前がViewModelだがViewに命令する → Presenter的

こうした役割の曖昧さは、コードの可読性や保守性、チームの認識共有を妨げます。まずは両者の本来の責務を明確にしましょう。


MVPとMVVMの違い

共通点

  • ModelとViewの仲介役
  • UIロジックの抽象化(例:ローディング表示、バリデーション)
  • 単体テストがしやすい構造を目指す

違い

項目 Presenter (MVP) ViewModel (MVVM)
Viewとの関係 protocol越しにViewを参照 Viewを知らない
UI更新 Viewに命令(showX) 状態を公開しViewが購読
状態管理 基本的に持たない(Passive View) 状態を内部に保持(@Published)
イベント通知 View→Presenter View→ViewModel

UIKitとSwiftUIでの選定指針

  • SwiftUI:宣言的UI+双方向バインディングMVVMが自然
  • UIKit:命令型UIが主流 → MVPがなじみやすい

ただしこれは目安です。UIが複雑ならUIKitでもMVVMを使うこともあり、逆もまた然り。大事なのはチームで認識を合わせ、設計意図を明確にすることです。


メリットとデメリット

MVP

メリット

  • 明示的な画面制御が可能
  • UIKitと親和性が高い
  • 理解しやすく学習コストが低い

デメリット

  • Viewとの双方向依存になりやすい
  • Viewのモックが必要でテストが煩雑
  • ロジックの重複や冗長化しやすい

MVVM

メリット

  • 状態ベースのテストが容易
  • 再利用性・保守性に優れる
  • SwiftUIとの相性が良好

デメリット

  • ViewModelが肥大化しやすい
  • UIイベントの扱いが煩雑になることも
  • 非SwiftUI環境ではバインディング実装が複雑

プロジェクトでどう判断する?

名前は実装の責務と一致しているべきです。状態管理しViewに公開するならViewModel、Viewの表示を制御するならPresenter。

また、責務の肥大化を避けたいなら、ViewModelの内部にドメインロジックを抱え込まず、UseCase層などへ分離するのが有効です。

アーキテクチャ選定に正解はありませんが、「自分たちがどの設計に基づいてコードを書いているのか」を共通認識として持つことが、健全な開発の第一歩になります。


名前と中身が一致しているか?
この問いをコードレビューや設計時に意識するだけで、プロジェクトの品質はぐっと上がります。

AIコーディングとゲーム:マインスイーパーとファイアーエムブレムの違い

最近、AIによるコーディングをマインスイーパーに例えた話を耳にした。内容としては、「AIが楽しいパズル的な部分をやってしまい、人間は運任せの意思決定だけをする」というものだった。

マインスイーパーでAIが安全なマスを全部開けてしまい、プレイヤーはただ爆弾を踏むか踏まないかの運ゲーをするような感じ。

一方で、ゲームには自動操作(オート機能)が受け入れられた例もある。例えばファイアーエムブレムのオート戦闘は、面倒な繰り返し作業を省きつつ、プレイヤーの戦略的な楽しみは奪わない。 では、なぜマインスイーパーの例えは「AIが楽しさを奪う」と感じられ、ファイアーエムブレムのオート機能は便利なものとして受け入れられたのだろうか?

AIは「何を代替するか」で評価が変わる

マインスイーパーの面白さは「論理的思考と推理」にある。もしAIがそこを担ってしまうと、プレイヤーは「ただクリックするだけ」の退屈な作業に追いやられる。

一方で、ファイアーエムブレムのオート戦闘は、プレイヤーが戦略的な決定をした上で、単純作業をAIに任せる仕組みになっている。 つまり、AIがカットするのは「繰り返しの手間」であって、「プレイヤーの楽しみ」ではない。

デザインされた世界でプレイするゲームと完全に話を置き換えられる訳ではないが、この違いは、そのままAIコーディングの話にも当てはまるんじゃないかなとか考えた。

AIコーディングの現実と変化する環境

AIがコードを補完したり、バグを自動で修正したりするのは、ファイアーエムブレムのオート機能に近い。エンジニアが「本質的な設計」や「創造的な部分」に集中できるなら、それは歓迎されるはずだ。

しかし、もしAIが設計や問題解決までほぼすべてこなすようになったら? エンジニアの仕事は「AIが出したコードをチェックするだけ」になり、まるで運ゲーマインスイーパーをやらされるような感覚に陥るかもしれない。

ここで問題になるのは、「個人的に受け入れたいかどうか」ではなく、「環境がそれを強制するかどうか」になりそう。

企業と労働市場がAIを前提に動き始めると…

現実問題として、企業の経営陣は効率とコスト削減を重視する。もしAIを活用することで開発スピードが上がり、人件費が抑えられるなら、積極的に導入するのは当然だ。

また、エンジニアの労働市場においても、AIを活用できる人材の価値が上がり、「AIを使わない人」は相対的に不利になる可能性が高い。そうなると、「AIを使いたいかどうか」ではなく、「AIを使わないと仕事にならない」状況 が生まれる。

AI時代にエンジニアが生き残るための選択肢

この状況に対して、エンジニアが取れる選択肢は主に3つあるんじゃなかろうか。

  1. AIを積極的に活用する
    • 最新のAIツールを学び、活用する側に回る。
    • 「AIをどう使うか」がスキルの一部になり、エンジニアの市場価値を高める。
  2. AIを補助として使い、自分の強みを別に作る
    • AIに任せる部分と、自分がやるべき部分を意識的に分ける。
    • 創造的な設計や、ユーザーのニーズを深く理解するスキルを磨く。
  3. AIに頼らないスタイルを貫く(ただしリスクあり)

    • AIを使わない企業や職種にこだわる(ただし、選択肢が限られる)。
    • コードを書く仕事ではなく、別の分野にシフトする。

結局、「AIに何を奪わせるか」を決めるのは自分かも

マインスイーパーのように「AIが楽しい部分を奪う」状況になるのか、ファイアーエムブレムのように「AIが面倒な部分を助ける」状況になるのか。 これは、エンジニア自身がどのようにAIを受け入れるか、どう使うかにかかってきそう。

環境が変われば「AIを受け入れざるを得ない」状況になるかもしれないが、その中で 「AIとどう共存するか」 を考えることが、これからのエンジニアに求められるスキルなのかもしれない。しらんけど。

SwiftのGenericsについてのメモ(その1)

概要

SwiftのGenericsを勉強しているのでそのメモ。個人的に難しくて使いこなせていない機能です。 とりあえずThe Swift Programming LanguageのGenericsのページを読んでいる。

Genericsとは

Generic code enables you to write flexible, reusable functions and types that can 
work with any type, subject to requirements that you define.
You can write code that avoids duplication and expresses its intent in a clear, 
abstracted manner.
  • Google翻訳ジェネリックコードを使用すると、定義した要件に応じて、任意の型で機能する柔軟で再利用可能な関数と型を記述できます。重複を避け、その意図を明確で抽象的な方法で表現するコードを書くことができます。」とのこと。
  • そういうやつ、ぐらいの認識です。
  • ArrayやDictionaryもGenericsだよ!気づいてなかったかな??
  • Genericsで出来ることって広範だけど、それぞれできることがラベル付けされてないから理解が進まない印象があったりする
  • よくつかう<T>は「placeholder type name」というらしい
    • 具体的な型が何であるかはここでは問わない
func swapTwoValues<T>(_ a: inout T, _ b: inout T) {
    let temporaryA = a
    a = b
    b = temporaryA
}

var someInt = 3
var anotherInt = 107
swapTwoValues(&someInt, &anotherInt)
// someInt is now 107, and anotherInt is now 3
  • Tの型は問わないが、2つの引数の型が同じであることを求めている
  • 引数に渡された値の型によってTの型が自動的に決まる

Type Parameters

Type Parametersとは

  • 先程のGenericsの手法はType Parametersと呼ばれる
    • 指定したplaceholderを引数の型、返り値、関数内で使う変数や定数などの型の指定(type annotation)、で使うことができる
  • カンマで区切れば複数のType Parameterを指定できる
  • extensionで拡張するときに特にType Parameterは指定しなくてもいい。

Type Parametersの名前つけ

  • 使われている場所とtype parameterとの間に関わりがあるならKey、Valueとか(Dictionary)Elementとか(Array)付けよう
  • とくに無ければT, U, Vがよく使われる

Type Constraints(型制約)

  • ParamaterTypeが特定のクラスを継承しているか、特定のprotocolに適合していることを制限することができる
    • DictionaryのkeyはHashableプロトコルに適合している必要がある(そういう制約をつけている)

Go TourのExercise

GoのTourのExerciseの回答

go-tour-jp.appspot.com

プログラミング言語 Goには、初学者が言語仕様を学べるTourというコンテンツがあります。

その中に練習問題(Exercise)があったので、自分の回答を学習メモとして記していきます。

Exercise: Slices

A Tour of Go

これちょっと説明文が分かりづらい気がしました。Pixメソッドの仕様のポイントを羅列します。

  • Picメソッドの引数で受け取るdx×dyのサイズの2次元配列を返す
  • 配列の中身は各座標のxyをもとにした整数値(uint8)を渡す
    • 別にxyをもとにしなくても整数値が0〜255の範囲であれば画像は生成される
      • 0を渡せば青一色の画像になる
    • 中身の整数値によって青色の濃さが変わる
      • 0に近いほど色が濃くなり、255に近いほど色が薄くなる
    • 例示されている整数値の計算パターン は使わなくてもよい
      • (x+y)/2x*yx^yが面白い画像ができるというだけ

コード

package main

import "golang.org/x/tour/pic"

func Pic(dx, dy int) [][]uint8 {
    // 2次元配列の初期化
    pic := make([][]uint8, dy)
    for y := 0; y < dy ;y++ {
        // 横列の配列を初期化
        row := make([]uint8, dx)
        for x := 0; x < dx ;x++ {
            // 色を表現する整数値を代入
            row[x] = uint8((x+y)/2)
        }
        // 横列の配列を保存
        pic[y] = row
    }
    return pic
}

func main() {
    pic.Show(Pic)
}

出力

f:id:higan_n:20190629144853p:plain

Exercise: Maps

A Tour of Go

コード

package main

import (
    "golang.org/x/tour/wc"
    "strings"
)

func WordCount(s string) map[string]int {
    // 文字列をスライスに分割
    words := strings.Fields(s)
    // Mapsの初期化
    m := make(map[string]int)
    // intのゼロ値は0なので、同じ単語(同じKey)のたびに1を足す
    // m[value]++でも可
    for _, value := range words {
        m[value] = m[value] + 1
    }
    return m
}

func main() {
    wc.Test(WordCount)
}

出力

PASS
 f("I am learning Go!") = 
  map[string]int{"Go!":1, "I":1, "am":1, "learning":1}
PASS
 f("The quick brown fox jumped over the lazy dog.") = 
  map[string]int{"The":1, "brown":1, "dog.":1, "fox":1, "jumped":1, "lazy":1, "over":1, "quick":1, "the":1}
PASS
 f("I ate a donut. Then I ate another donut.") = 
  map[string]int{"I":2, "Then":1, "a":1, "another":1, "ate":2, "donut.":2}
PASS
 f("A man a plan a canal panama.") = 
  map[string]int{"A":1, "a":2, "canal":1, "man":1, "panama.":1, "plan":1}

Exercise: Fibonacci closure

A Tour of Go

コード

package main

import "fmt"

func fibonacci() func() int {
    // 保存されるスライスの`s`
    s := []int{}
    return func () int {
        length := len(s)
        value:= 0
        switch length {
            case 0:
                value = 0
            case 1:
                value = 1
            default:
                value = s[length - 1] + s[length - 2]
        }
        s = append(s, value)
        return value
    }
}

func main() {
    f := fibonacci()
    for i := 0; i < 10; i++ {
        fmt.Println(f())
    }
}

出力

0
1
1
2
3
5
8
13
21
34

cocoapodsでRxSwiftをインストールした際に出てきた謎のエラー「Expected ',' separator」

Environment

  • Swift 4.2
  • Cocoapods 1.5.3
  • XCode 10.0

Problem

表題の通り、Expected ',' separatorっていうエラーがRxSwiftのパッケージ側に出てきて、ビルドがうまくいかなかった。 Cocoapodsで管理しているのですが、Pods以下はロックされていて自分でいじっては居ないし、じゃあRxSwiftのバグかなと思って調べても特に出てこない。

それで、エラーが出ている箇所のソースコードを見ると、引数によくわからないものを渡していることに気づきました。 それでリモートリポジトリのソースコードを確認すると、エラーが出ているローカルのソースコードと違っていました。

// my local library source cord(RxSwift/Observables/Timer.swift)

return _parent._scheduler.scheduleRelative(self, dueTime: _parent._dueTime) { (_f_`) -> Disposable in
// https://github.com/ReactiveX/RxSwift/blob/53cd723d40d05177e790c8c34c36cec7092a6106/RxSwift/Observables/Timer.swift#L78

return _parent._scheduler.scheduleRelative(self, dueTime: _parent._dueTime) { (`self`) -> Disposable in

😳!

Solution

まあ、たぶんXCodeのSwift4.2シンタックスへの自動変換のときに、気づかないで変更しちゃったのかな、と思います。 解決方法としては、以下のようにCocoapodsのキャッシュを削除した上で、プロジェクトのPodsディレクトリ以下を削除して再インストールしましょう。

念の為、DerivedData以下のビルドキャッシュも消したほうが良いかもしれません。

rm -rf ~/Library/Caches/CocoaPods
rm -rf [path to project]/Pods

// optional
rm -rf ~/Library/Developer/Xcode/DerivedData

pod install

訂正(2019/09/27 15:48)

これ、Build Phaseで設定していたswiftlint autocorrectの仕業でした。アホすぎる。。