生成AIを使いながら毎年恒例のQiitaリーダーを作ってみる(ただし今年はiOS)

この記事は[NTTテクノクロス Advent Calendar 2024　シリーズ1](https://qiita.com/advent-calendar/2024/ntt-tx)の15日目です。

こんにちは、NTTテクノクロスの戸部 @etctaro と申します。

普段は社内でモバイルアプリ開発関連の技術支援や社内向けのノウハウ記事執筆、社内研修講師活動、社内コミュニティ活動などを行なっています。

…と、毎年書いているのですが、最近は生成AIに関する開発を行うプロジェクトにも携わっています。

毎年恒例のアドカレですが、今年もいつものQiitaリーダーを作る話をします。

★ところで、記事の前に宣伝です。

先日まで開催されていた技術書典17では当社の仲間と書籍を出しました。
今回私が執筆した本は以下の通り、生成AIを使ったアプリ開発についての話です。

私たちが社内で実施した研修をそのまま本にしたものです。
初めてオフライン会場で物理本を頒布することもできました。なかなか感慨深いです。

- [生成AIではじめるプログラミング体験ガイド](https://techbookfest.org/product/5aEpfLdzvmE04cNLGE6J2v)

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/e1143d94-8e43-c596-e6b8-1de0fa9a5d9d.png)

技術書典のオンラインマーケットからいつでもダウンロードできますので、ぜひお手にとっていただければと思います。


# はじめに
上記で紹介した書籍では計算機アプリを作成し、ブラッシュアップしていく過程を通じて生成AIをプログラミングに利用することを体験していただく、という内容です。

私自身はAndroidアプリ作りの方が得意ですが、最近プロジェクトの中で生成AIを使いながら iOSアプリを作るという経験をしましたので、ここでも書籍の内容を踏まえながらiOSのQiitaリーダーを作る流れを実践してみます。

ぜひ、皆さんも普段触れない、またはあまり得意でないフレームワークや環境でアプリ作りをするというのを試してみていただけると良いと思います。

# 対象読者

- とりあえず新しいものに触ってみたい人
- 生成AIを使ったアプリ作りを試してみたい人
- 技術書典で書籍を手に取っていただいた人

# こういう人にも
今回はSwiftUIよせで記載しますが、ComposeやFlutterなど他のフレームワークでも同じことができると思います。
色々と試してみると良いかもしれません。

# 注意点

- 生成AIはランダム性があるため常に同じ結果になるとは限りません
- もしエラーが出るようであれば、生成し直すなどしてください
- 今回は弊社の生成AIツールである「[ChatTX](https://www.ntt-tx.co.jp/products/chattx/)」を使いますが、一般的な生成AIツール（ChatGPTやPerplexityなど）でも同様のことができると思われますので読み替えてください。ただし、モデルだけは後述のとおりgpt-4oに合わせるのが良いと思います

なお、この後の説明では、以下のnoteでLLMの回答を表すものとします。

:::note info
この部分はLLMからの回答
:::

# 読んで得られること
- 生成AIを使ってアプリ作りをする流れを体験できる
- よくあるタイプのアプリを作ることができるようになることで、他のアプリに水平展開できる

# 環境

- ChatTX
- 生成AIのモデル： GPT-4o
- 開発環境：Xcode15.2（少し古いです）
- 動作環境：iOS17系（最新ではありません）


# はじめの一歩
さて、アプリ作りですが、最初はXcodeのテンプレートに沿ってアプリを作ります。ここは特にひねりはありません。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/f42305d2-d2b7-daec-2c4d-bfe0f9740d6f.png)

使うか、使わないか決めていないのですが、SwiftDataを使うプロジェクトとしていました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/77758036-a66d-c023-1679-970cffa917f3.png)

省略しますが、大体以下のようなContentViewが生成されるはずです。

```swift
//
//  ContentView.swift
//  QiitaReader
//
//  Created by Tobe Yutaro on 2024/12/11.
//

import SwiftUI
import SwiftData

struct ContentView: View {
    @Environment(\.modelContext) private var modelContext
    @Query private var items: [Item]

    var body: some View {
        NavigationSplitView {
            List {
// ・・・以下略
```

# 早速生成AIにたよってみる

「〇〇えも〜ん」ということで、生成AIにQiitaリーダのテンプレートを作ってもらいます。

書籍の中では以下のような流れで記載しています。

1. アプリのソースコードを生成するためのプロンプトを検討する
2. ソースコードを生成する
3. ソースコードを使って動作確認する
4. 改善点・課題をリストアップし、改善のためのプロンプトを検討する
5. 2から4のサイクルを繰り返す

このような流れでQiitaリーダーを作成してみます。

# 1. アプリのソースコードを生成するためのプロンプトを検討する

ではプロンプトを考えてみます。
今回[昨年と同様のQiitaリーダー](https://qiita.com/etctaro/items/5f142b0661e761756b36#%E3%81%A8%E3%82%8A%E3%81%82%E3%81%88%E3%81%9A%E3%81%AE%E5%AE%8C%E6%88%90%E5%93%81)とします。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/7389045f-765c-d603-9507-01fe48526174.png)


このようなアプリを作るための仕様を考えてみます。
仕様と書きはしたものの、大雑把な内容です。

```
以下のような2画面のアプリをSwiftUIで作ろうと思います。サンプルコードを生成してください。

★概要
- Qiitaというサイトの記事の一覧を、RestAPIを実行して取得する。
- レスポンスに含まれる記事のタイトルを一覧表示する。
- 各記事のタイトルをタップすると、記事のURLをWebViewで表示する詳細画面に遷移する。

■一覧画面
画面の要素は以下の通り

- 記事のタイトルを一覧表示する。
- 右下にFloating Action Buttonを持つ。FABをタップすると一覧の内容を更新する。
- ★ただし、今はRestAPIのことは考慮しなくて良いです。
 
■詳細画面
- 一覧画面でタップした記事のURLをWebViewで表示する画面。
```

最初からあれこれやるとなかなか大変なので、まずはシンプルに実装してみます。

# 2. ソースコードを生成する

さて、ここからが生成AIの本領発揮です。
先ほど書いたプロンプトを使ってソースコードを生成してみます。

:::note info

以下は、SwiftUIを使用して2画面のアプリを作成するためのサンプルコードです。このコードでは、Qiitaの記事のタイトルを一覧表示し、各タイトルをタップすると詳細画面でWebViewを使用して記事を表示します。RestAPIの実装は省略していますが、データの取得部分をモックデータで代用しています。

```swift
import SwiftUI
import WebKit

// モデル
struct Article: Identifiable {
    let id = UUID()
    let title: String
    let url: String
}

// WebViewをラップするUIViewRepresentable
struct WebView: UIViewRepresentable {
    let url: URL

    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        let request = URLRequest(url: url)
        uiView.load(request)
    }
}

// 一覧画面
struct ArticleListView: View {
    @State private var articles: [Article] = [
        Article(title: "Sample Article 1", url: "https://www.example.com/1"),
        Article(title: "Sample Article 2", url: "https://www.example.com/2"),
        Article(title: "Sample Article 3", url: "https://www.example.com/3")
    ]

    var body: some View {
        NavigationView {
            List(articles) { article in
                NavigationLink(destination: ArticleDetailView(url: article.url)) {
                    Text(article.title)
                }
            }
            .navigationTitle("Articles")
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    Button(action: refreshArticles) {
                        Image(systemName: "arrow.clockwise.circle.fill")
                            .font(.largeTitle)
                            .padding()
                    }
                }
            }
        }
    }

    // 記事のリフレッシュ（モックデータの更新）
    private func refreshArticles() {
        // ここでAPIを呼び出してデータを更新する
        // 今はモックデータを更新するだけ
        articles.append(Article(title: "New Sample Article", url: "https://www.example.com/new"))
    }
}

// 詳細画面
struct ArticleDetailView: View {
    let url: String

    var body: some View {
        WebView(url: URL(string: url)!)
            .navigationTitle("Article Detail")
            .navigationBarTitleDisplayMode(.inline)
    }
}

// プレビュー
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ArticleListView()
    }
}
```

説明
Article構造体は、記事のタイトルとURLを保持します。
WebViewは、WKWebViewをラップしてSwiftUIで使用できるようにしたものです。
ArticleListViewは、記事のタイトルをリスト表示し、NavigationLinkを使用して詳細画面に遷移します。
ArticleDetailViewは、選択された記事のURLをWebViewで表示します。
refreshArticles関数は、モックデータを追加することでリストを更新します。実際のアプリではここでAPIを呼び出してデータを更新します。
このサンプルコードを基に、実際のAPI呼び出しやデータのパースを追加してアプリを完成させてください。
:::


なんとなくいい感じですね。これをプロジェクトに取り込んでみることにしましょう。

もともとContentViewとなっているのをそのまま今回生成された `ArticleListView` に置き換えます。
また、その他の部分のソースコードもほとんどそのまま利用することにします。

※なお、暗黙的に `www.example.com` を使っています。よく使われるサンプル用のURLなのでさほど問題はありませんが、気になるようなら `google.co.jp` など有名なURLにするか、localhostなどにしておきましょう。


```swift
import SwiftUI
import SwiftData
import WebKit

// モデル
struct Article: Identifiable {
    let id = UUID()
    let title: String
    let url: String
}

// WebViewをラップするUIViewRepresentable
struct WebView: UIViewRepresentable {
    let url: URL
//以下そのまま利用

// 詳細画面
struct ArticleDetailView: View {
    let url: String

    var body: some View {
        WebView(url: URL(string: url)!)
            .navigationTitle("Article Detail")
            .navigationBarTitleDisplayMode(.inline)
    }
}

// ここはContentView()から書き換え
#Preview {
    ArticleListView()
}

```

# 3. ソースコードを使って動作確認する

Previewも用意していますので、Xcode上でプレビューを確認できます。さて、どうなるか。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/0db82b9a-0e02-ea41-1117-adcef9480ace.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/9434da97-9373-67e6-6524-37935d7b2300.png)

それぞれの画面がいい感じに実装されていることがわかるかと思います。
詳細画面の方はもう何もすることはないですね。一覧からうまくURLの情報を渡せるようにします。

# 4. 改善点・課題をリストアップし、改善のためのプロンプトを検討する

さて、ここまでは準備段階で、ここからが本題です。
不足していることがいくつかあります。

- FABをタップするとQiitaのRestAPI( 参照: https://qiita.com/api/v2/docs#get-apiv2items )を叩く
- Restの結果をリストとして画面に反映する

一覧画面でこれを実現するためのプロンプトを考えてみます。
※RestAPIのURLはこちらとします: `https://qiita.com/api/v2/items?page=1&per_page=20`

公式サイトのドキュメントをサンプルとして使ったプロンプトを以下の通り示しています。
このように、生成AIを使うと、リクエストの処理が実装しやすいです。

````
FABをタップすると、QiitaのRestAPIを実行します。

- URL: https://qiita.com/api/v2/items?page=1&per_page=20
- レスポンスボディの例は以下の通りです。これは1つの記事に関する情報で、これが20個取得されます
- この中から、titleとurlの要素をつかいます

```
[
  {
    "rendered_body": "<h1>Example</h1>",
    "body": "# Example",
    "coediting": false,
    "comments_count": 100,
    "created_at": "2000-01-01T00:00:00+00:00",
    "group": {
      "created_at": "2000-01-01T00:00:00+00:00",
      "description": "This group is for developers.",
      "name": "Dev",
      "private": false,
      "updated_at": "2000-01-01T00:00:00+00:00",
      "url_name": "dev"
    },
    "id": "c686397e4a0f4f11683d",
    "likes_count": 100,
    "private": false,
    "reactions_count": 100,
    "stocks_count": 100,
    "tags": [
      {
        "name": "Ruby",
        "versions": [
          "0.0.1"
        ]
      }
    ],
    "title": "Example title",
    "updated_at": "2000-01-01T00:00:00+00:00",
    "url": "https://qiita.com/Qiita/items/c686397e4a0f4f11683d",
    "user": {
      "description": "Hello, world.",
      "facebook_id": "qiita",
      "followees_count": 100,
      "followers_count": 200,
      "github_login_name": "qiitan",
      "id": "qiita",
      "items_count": 300,
      "linkedin_id": "qiita",
      "location": "Tokyo, Japan",
      "name": "Qiita キータ",
      "organization": "Qiita Inc.",
      "permanent_id": 1,
      "profile_image_url": "https://s3-ap-northeast-1.amazonaws.com/qiita-image-store/0/88/ccf90b557a406157dbb9d2d7e543dae384dbb561/large.png?1575443439",
      "team_only": false,
      "twitter_screen_name": "qiita",
      "website_url": "https://qiita.com"
    },
    "page_views_count": 100,
    "team_membership": {
      "name": "Qiita キータ"
    },
    "organization_url_name": "qiita-inc",
    "slide": false
  }
]
```

RestAPIを実行する部分と、実行結果をArticleオブジェクトとして保持する処理を追加してください。
````

さて、ここまでやった結果は以下の通りです。

:::note info

以下のコードは、QiitaのREST APIを使用して記事のリストを取得し、タイトルとURLを表示するSwiftUIアプリのサンプルです。URLSessionを使用してAPIを呼び出し、レスポンスをデコードしてArticleオブジェクトとして保持します。

```
import SwiftUI
import WebKit

// モデル
struct Article: Identifiable, Decodable {
    let id = UUID()
    let title: String
    let url: String

    private enum CodingKeys: String, CodingKey {
        case title
        case url
    }
}

// WebViewをラップするUIViewRepresentable
struct WebView: UIViewRepresentable {
    let url: URL

    func makeUIView(context: Context) -> WKWebView {
        return WKWebView()
    }

    func updateUIView(_ uiView: WKWebView, context: Context) {
        let request = URLRequest(url: url)
        uiView.load(request)
    }
}

// 一覧画面
struct ArticleListView: View {
    @State private var articles: [Article] = []
    @State private var isLoading = false

    var body: some View {
        NavigationView {
            List(articles) { article in
                NavigationLink(destination: ArticleDetailView(url: article.url)) {
                    Text(article.title)
                }
            }
            .navigationTitle("Articles")
            .toolbar {
                ToolbarItem(placement: .bottomBar) {
                    Button(action: fetchArticles) {
                        Image(systemName: "arrow.clockwise.circle.fill")
                            .font(.largeTitle)
                            .padding()
                    }
                    .disabled(isLoading)
                }
            }
        }
    }

    // 記事の取得
    private func fetchArticles() {
        guard let url = URL(string: "https://qiita.com/api/v2/items?page=1&per_page=20") else {
            return
        }

        isLoading = true

        URLSession.shared.dataTask(with: url) { data, response, error in
            defer { isLoading = false }

            if let error = error {
                print("Error fetching articles: \(error)")
                return
            }

            guard let data = data else {
                print("No data received")
                return
            }

            do {
                let articles = try JSONDecoder().decode([Article].self, from: data)
                DispatchQueue.main.async {
                    self.articles = articles
                }
            } catch {
                print("Error decoding JSON: \(error)")
            }
        }.resume()
    }
}

// 詳細画面
struct ArticleDetailView: View {
    let url: String

    var body: some View {
        WebView(url: URL(string: url)!)
            .navigationTitle("Article Detail")
            .navigationBarTitleDisplayMode(.inline)
    }
}

// プレビュー
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ArticleListView()
    }
}
```

説明
Article構造体は、Decodableプロトコルに準拠しており、APIレスポンスから直接デコードできるようにしています。
fetchArticles関数は、QiitaのAPIを呼び出して記事を取得します。URLSessionを使用して非同期でデータを取得し、JSONDecoderを使用してデコードします。
isLoadingフラグを使用して、API呼び出し中にボタンを無効化しています。
WebViewは、記事のURLを表示するために使用されます。
このコードを実行すると、FABをタップすることでQiitaのAPIから記事を取得し、リストに表示されます。

:::

このソースコードを反映させて、実際に動かしてみましょう。以下の通りになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/8208390a-8926-69c0-df6a-5c2bcc5f9fa3.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/339948d9-5dbe-f7a5-49b4-9ffe20348bdd.png)

大体やりたいことができていますね。完璧。

今回Xcodeで開発をすることで、ソースコードの生成と動作確認がスムーズに実施できるようになっています。
その点も生成AIがうまく活用できることにつながっています。

書籍の中で触れていますが、このように生成から動作確認が短いスパンでできればそれだけ軌道修正しやすくなります。

# もう少し修正をしてみたい
よくみるとこのリロードボタン、リストと縦並びになっているのが気になります。FABっぽくしてみます。
ちょっと修正してみましょう。

現時点の実装を生成AIと振り返るというのを挟むのも良いです。
プロンプト例は以下の通り。

````
一覧画面の更新ボタンについて、リストと縦並びにするのではなく、リストに重なるようにすることはできますか？

★現在下記のように実装しています。

```
import SwiftUI
import SwiftData
import WebKit

// モデル
struct Article: Identifiable, Decodable {
let id = UUID()
let title: String
let url: String

private enum CodingKeys: String, CodingKey {
case title
case url
}
}

// WebViewをラップするUIViewRepresentable
struct WebView: UIViewRepresentable {
let url: URL

func makeUIView(context: Context) -> WKWebView {
return WKWebView()
}

func updateUIView(_ uiView: WKWebView, context: Context) {
let request = URLRequest(url: url)
uiView.load(request)
}
}

// 一覧画面
struct ArticleListView: View {
@State private var articles: [Article] = []
@State private var isLoading = false

var body: some View {
NavigationView {
List(articles) { article in
NavigationLink(destination: ArticleDetailView(url: article.url)) {
Text(article.title)
}
}
.navigationTitle("Articles")
.toolbar {
ToolbarItem(placement: .bottomBar) {
Button(action: fetchArticles) {
Image(systemName: "arrow.clockwise.circle.fill")
.font(.largeTitle)
.padding()
}
.disabled(isLoading)
}
}
}
}

// 記事の取得
private func fetchArticles() {
guard let url = URL(string: "https://qiita.com/api/v2/items?page=1&per_page=20") else {
return
}

isLoading = true

URLSession.shared.dataTask(with: url) { data, response, error in
defer { isLoading = false }

if let error = error {
print("Error fetching articles: \(error)")
return
}

guard let data = data else {
print("No data received")
return
}

do {
let articles = try JSONDecoder().decode([Article].self, from: data)
DispatchQueue.main.async {
self.articles = articles
}
} catch {
print("Error decoding JSON: \(error)")
}
}.resume()
}
}

// 詳細画面
struct ArticleDetailView: View {
let url: String

var body: some View {
WebView(url: URL(string: url)!)
.navigationTitle("Article Detail")
.navigationBarTitleDisplayMode(.inline)
}
}



#Preview {
ArticleListView()
}

```
````

結果としては、ZStackを使ったソースコードが生成されます。

:::note info

```swift
    var body: some View {
        NavigationView {
            ZStack {
                List(articles) { article in
                    NavigationLink(destination: ArticleDetailView(url: article.url)) {
                        Text(article.title)
                    }
                }
                .navigationTitle("Articles")

                VStack {
                    Spacer()
                    HStack {
                        Spacer()
                        Button(action: fetchArticles) {
                            Image(systemName: "arrow.clockwise.circle.fill")
                                .font(.largeTitle)
                                .padding()
                                .background(Color.blue)
                                .foregroundColor(.white)
                                .clipShape(Circle())
                                .shadow(radius: 10)
                        }
                        .padding()
                        .disabled(isLoading)
                    }
                }
            }
        }
    }
```
:::

この状態で実行してみると以下の通りとなります。一応、想定通り。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/151249/46334f43-7cf9-d696-0fb1-e3567f27c855.png)

# もし興味があれば

実際にここまで私と同じような取り組みをしていただいたなら、今回のアプリにさらに色々と追加したい欲が出てくるのではないかと思います。

- 自分のお気に入りの記事をDBに登録する機能
- リストじゃなくてグリッドで表示する
- その他全体的な見た目の調整

ぜひトライしてみてください。

一方で単に生成したソースコードを利用する場合以下の点が気になります。

- リファクタリングしなくても良いのか？
- あまり理解せずに実装できてしまっている→ソースコードの解説を生成AIにさせるのも良いかもしれません
- 最新のAPIには対応していないので、その点はユーザ側が勉強する必要はある（はず。今回は特に問題になりませんでした）

# おわりに
さて、今回は昨今のブームに乗っかり、生成AIを活用してiOS版Qiitaリーダーを実装してみました。
私が実践したように、普段使い慣れていないフレームワークでも実装できるという点が生成AIを活かした開発の強みの一つになるかと思います。
サクッと動かせるソースコードが作られるのも良い。
ぜひ自身の知見を広げるために活用していただくのが良いと思います。

# 令和TXこそこそ噂話
シリーズ1の16日目は @kiwa-y さんが「テスト自動化のなにか」について語ってくださるようですよ。
明日もぜひご覧ください！