実に Claude Code と同じ画面ができあがります…が、右上の歯車ボタンが「・・・」になっているのが、ちょっと違いますね。画面遷移自体は「設定」→「著作権表示」となっているのですが。
このあたり UI 設計にある
## 2. 画面遷移
```mermaid
flowchart LR
A[チャット画面] -->|メニューから設定| B[設定画面]
B -->|戻る| A
B -->|著作権情報| C[著作権情報画面]
C -->|戻る| B
B -->|送信者ID編集| D[送信者ID編集ダイアログ]
D -->|保存| B
D -->|キャンセル| B
の部分で「メニューからの設定」の解釈の違いっぽいです。
考察
このように後から UI 設計書を追加して、それに従って既存のコードに画面を追加することは可能です。プロンプトでちまちま追加するりも、設計書.md のようにまとめて AI に渡していたほうが効率的なのは確かです。
が、じゃあ、UI 設計書はどこまで作ればいいのか?という問題があります。今回のように、数行のプロンプトで作った場合、Claude Code と GitHub Copilot では異なる画面ができてしまいます。個人開発ならばそれでもいいのですが、会社のシステムのような場合はこれでは困ります。となると、なんらかの画面設計書を作った上で、その画面フォーマットンに合わせて、どちらの AI を使っても同じ画面できるのが望ましいのです。
おそらく、こんな風なあまりテストの必要ない画面ならば AI へのプロンプトの指示で十分です。その後に、ビルド&実行して、チャットを使って修正指示を出してもそれほど手間ではないでしょう。 実のところ「送信継続時間」が本当に、その時間で停止しているのかなどの動作チェックが必要なのですが、これはまた別の形でテストをします。
SettingRow(
title = "送信継続時間",
value = BleAdvertiserManager.DEFAULT_ADVERTISE_DURATION_MS.toSecondsLabel()
)
HorizontalDivider()
SettingRow(
title = "重複排除 TTL",
value = DuplicateFilter.DEFAULT_TTL_MS.toSecondsLabel()
)
HorizontalDivider()
SettingRow(
title = "参加者タイムアウト",
value = PeerRegistry.DEFAULT_TIMEOUT_MS.toSecondsLabel()
)
HorizontalDivider()
普段使いのコーディングでは VSCode + GitHub Copilot を使って「AI ペアプロ」という形で進めていきます。AI ペアプロなので、常にプログラマが並走しています。AI に任せっきりというわけでもなく、AI が出力してきたコードを鵜呑みするわけでもなく、ある程度ですが AI コードを常にレビューしながら進めていきます。まあ、いろいろ面倒になって、ちょっとだけ動作確認した後に Git にコミットしてしまうことも多いのですが、場合によっては 1 日分の AI コードを戻してしまうことも多いです。
BLE 絡みと Web API 絡みでプロトタイプ的なコードを結構書いてきたのですが、それ以上の品質に AI コーディングが辿り着くのか?というと、現状では何とも言えません。このあたりは、私が「AI ペアプロ」に留まっている理由でもあり、巷のテクニカルな記事をみると大規模で手間のかかる AI コーディングならば良さそうなのですが、こう一歩ずつ試してくようなプロトタイプ型のコーディングではやっぱり「AI ペアプロ」のほうがよさそうな気がします。
とはいえ、手元で「Claude Code」を入れて(実際には Claude Cowork 用に入れたのですが)試してみると、ああ、確かに Web アプリケーションならば、仕様書を与えてコーディングさせることは可能っぽいです。というか、逆に言えば「AI ペアプロ」的な使い方は Claude Code だとやり辛いです。逆に言えば、夜間に一括でできるようなバッチ的な処理には非常に向いています。おそらく、単体テストの自動化と再帰的なコード修正がやり辛いのは、ここのバッチ的な処理が原因かもしれません。
という訳で、ここまでが愚痴…
AI ペアプロ型とバッチ型の両方を試してみる
恐らく一長一短があると思うのですが、
VSCode + GitHub Copilot を使った「AI ペアプロ」型
Claude Code を使った「バッチ型」
の両方を試してみます。巷のブログ記事だと、「Web サイトが一瞬でできました」タイプが多いので、仕様が決まったら一気に作る Claude Code のほうが向いているような気もしますが、そのあたりの非 Web アプリの場合はどうなのか?というお試しです。
mermaid sequenceDiagram actor User as User participant Screen as ChatScreen participant Activity as MainActivity participant Repo as ChatRepository participant Service as BleChatService participant Codec as MessageCodec participant Advertiser as BleAdvertiserManager
想定する職業はばらばらなので、自分の職種とは違うものが多いとは思うのですが、使い方のヒントにはなると思います。特に、手元に PDF やテキストデータ、Word ファイルなどで情報が文書として残っている場合は、お得に NotebookLM を活用できます。 ひとまず、NotebookLM で必要そうなファイルを突っ込んでみて、何か質問してみると、きっちりと PDF ファイルの内容を理解して回答してくれることがわかります。抜き出しだけではなくて、箇条書きでまとめたり表形式にフォーマットしなおしたりすることも可能です。
接触確認アプリでは Android/iOS の相互で BLE 通信を行うために、iOS での発信機のチェックもしておきます。 iOS の場合は、16 bit Service UUID 形式と Manufacturer Data 形式では発信できません。が、”発信できないこと” を確認するために、あえて両方の実装もしてあります。実際に iOS/Android の受信機で受信ができないことを確認してください。
EN API 形式の発信は、16 bit Service UUID を指定して発信するパターンですが、iOS では送信できません。送信はできないのですが、確認のためにコードは作ってあります。Android の受信機で受信できないことを確認してください。 たまに、Android/iOS の受信機に到達することがあり(このときのアドバタイズデータはランダム値になっています)、微妙な感じがするのですが、使えないのは確かです。
class ENSimTransmitter: NSObject, ObservableObject {
private var peripheralManager: CBPeripheralManager?
private let serviceUUID = CBUUID(string: "FD6F") // Exposure Notification 16-bit UUID
private let altServiceUUID = CBUUID(string: "FF00") // Alternative UUID for testing
@Published var isTransmitting = false
@Published var transmissionStatus = "停止中"
@Published var bluetoothState = "Unknown"
@Published var localName = "ENSim"
@Published var useAltService: Bool = false
@Published var rpi: Data = ENSimTransmitter.generateRandomRpi()
override init() {
super.init()
setupPeripheralManager()
}
private func setupPeripheralManager() {
peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
}
func startTransmitting() {
guard let manager = peripheralManager else {
print("PeripheralManager が初期化されていません")
return
}
guard manager.state == .poweredOn else {
print("Bluetooth が利用できません (state: \(manager.state.rawValue))")
return
}
guard !isTransmitting else {
print("既にアドバタイズ中です")
return
}
let selectedService = useAltService ? altServiceUUID : serviceUUID
let serviceData: [CBUUID: Data] = [selectedService: rpi]
let advertisementData: [String: Any] = [
CBAdvertisementDataServiceUUIDsKey: [selectedService],
CBAdvertisementDataLocalNameKey: localName,
// CBAdvertisementDataServiceDataKey: serviceData
]
manager.startAdvertising(advertisementData)
isTransmitting = true
transmissionStatus = "発信中..."
print("📡 EN シミュレーション発信開始")
print(" Service UUID (16-bit): \(useAltService ? altServiceUUID.uuidString : serviceUUID.uuidString)")
print(" Local Name: \(localName)")
print(" RPI (hex): \(rpi.map { String(format: "%02X", $0) }.joined())")
}
func stopTransmitting() {
guard let manager = peripheralManager, isTransmitting else { return }
manager.stopAdvertising()
isTransmitting = false
transmissionStatus = "停止中"
print("EN シミュレーション発信停止")
}
private static func generateRandomRpi() -> Data {
// let bytes = (0..<16).map { _ in UInt8.random(in: 0...255) }
// ランダムな uuid を生成して RPI として使用(デバッグ用)
// 送信は成功するが、Service Data の内容はランダム値になってしまうので、
// 実質利用ができない。
let uuid = UUID()
let uuidBytes = withUnsafeBytes(of: uuid.uuid) { Array($0) }
let bytes = Array(uuidBytes.prefix(16))
return Data(bytes)
}
}
Manufacturer Data 形式の発信
自由な形式でデータをブロードキャストする場合は、Manufacturer Data 形式で発信するのが一番いいのですが、これも iOS では使えません。これも、使えないことを確認するためにコードを作ってあります。 先に書いた通り、startAdvertisingRawIBeacon 関数を作ってもデータは送信できません。
/// Advertises custom manufacturer data (often consumed as scan response data on the scanner side).
/// フォーマット: [0]=0x02 (type), [1]=0x10 (length=16), [2..17]=TempId(16byte)
class ManufacturerDataTransmitter: NSObject, ObservableObject {
private var peripheralManager: CBPeripheralManager?
@Published var isTransmitting = false
@Published var transmissionStatus = "停止中"
@Published var bluetoothState = "Unknown"
@Published var localName: String = "MFG"
/// 16-bit company identifier (Little Endian in the payload). Default: 0xFFFF for testing.
@Published var companyId: UInt16 = 0xFFFF
let beacon_type = 0x02
let beacon_length = 0x10
/// Arbitrary manufacturer payload. Default 16 zero bytes for easy overriding.
@Published var tempIdBytes: Data = Data(repeating: 0x00, count: 16)
/// Last advertisement dictionary for debugging.
private(set) var lastAdvertisementData: [String: Any]? = nil
override init() {
super.init()
setupPeripheralManager()
}
private func setupPeripheralManager() {
peripheralManager = CBPeripheralManager(delegate: self, queue: nil)
}
/// Start advertising manufacturer data. Uses CBAdvertisementDataManufacturerDataKey which may appear in scan response on the scanner side depending on size and platform rules.
func startTransmitting() {
guard let manager = peripheralManager else {
print("PeripheralManager が初期化されていません")
return
}
guard manager.state == .poweredOn else {
print("Bluetooth が利用できません (state: \(manager.state.rawValue))")
return
}
guard !isTransmitting else {
print("既にアドバタイズ中です")
return
}
// Build manufacturer data: company ID (little endian) + payload.
var mfgData = Data()
mfgData.append(UInt8(companyId & 0xFF))
mfgData.append(UInt8((companyId >> 8) & 0xFF))
mfgData.append(UInt8(beacon_type))
mfgData.append(UInt8(beacon_length))
mfgData.append(tempIdBytes)
let advertisementData: [String: Any] = [
CBAdvertisementDataManufacturerDataKey: mfgData,
CBAdvertisementDataLocalNameKey: localName
]
lastAdvertisementData = advertisementData
manager.startAdvertising(advertisementData)
isTransmitting = true
transmissionStatus = "発信中..."
print("📡 Manufacturer 発信開始")
print(String(format: " Company ID: 0x%04X (LE)", companyId))
print(" tempIdBytes (hex): \(tempIdBytes.map { String(format: "%02X", $0) }.joined())")
print(" Local Name: \(localName)")
}
func stopTransmitting() {
guard let manager = peripheralManager, isTransmitting else { return }
manager.stopAdvertising()
isTransmitting = false
transmissionStatus = "停止中"
print("Manufacturer 発信停止")
}
}
実行
左から
Android で受信
iPhone で受信
iPhone で発信
という状態です。iBeacon の UUID は同じなので、major と minor で判断をします。