iPad で手書きノートアプリを作ろうとした方なら、一度は PencilKit に触れてみて「思ったより簡単だな」と感じた直後に「保存ってどうやるの? Apple Pencil Pro の Squeeze はどう取るの? クラウド同期はどうする?」で手が止まった経験があるのではないでしょうか。
私自身、3年ほど前から iPad 向けの手書き系アプリを何本か運用していますが、PencilKit は「最低限の動くもの」と「App Store で勝負できるもの」の差が想像以上に大きいフレームワークです。PKCanvasView を 1 行貼るだけでお絵かきはできますが、そこから先で多くの方が挫折します。
ここでは Rork(とくに Rork Max のネイティブ生成)を使って PencilKit ベースの手書きノートアプリを設計するときに、私が実際に踏んできた地雷とその回避策を体系的にまとめます。Apple Pencil Pro の新機能(Squeeze・Barrel Roll・Hover・Haptic)への対応、PKDrawing の永続化、CloudKit を介した端末間同期、そして手書き文字を検索可能にする OCR レイヤーまで、本番投入できるパターンとして整理しています。
なぜ PencilKit を選ぶのか — 自前描画と比べた損得勘定
最初に避けて通れない設計判断があります。手書きアプリの描画レイヤーを「PencilKit」「Metal で自前」「サードパーティ(DrawsanaKit、PSPDFKit など)」のどれにするか、です。
私は迷ったら PencilKit を選びます。理由は次の3つです。
第一に、Apple Pencil Pro の新機能(Squeeze・Barrel Roll・Hover)に追加実装ほぼ無しで対応できます。自前描画でこれを実装すると、UIPencilInteraction と UIHoverGestureRecognizer、PHASE のハプティクス制御をすべて自前で書くことになり、Pro 非対応端末の検出ロジックも組まなければなりません。
第二に、Apple が毎年 PencilKit を磨き続けており、PKToolPicker の見た目や挙動が iOS 純正の「メモ」「フリーボード」と揃います。ユーザーは標準アプリの操作感を期待してくるので、これに沿うこと自体が UX の競争力になります。
第三に、Scribble(手書き文字の自動テキスト変換)や PKDrawing.image() による高速ラスタライズが無料で手に入ります。自前で書くと OCR と PDF 変換の品質を担保するだけで数ヶ月かかります。
逆に PencilKit を避けるべきケースもあります。漫画・イラスト制作のように複雑なレイヤー機能・カスタムブラシエンジンが核になるアプリでは、Metal で自前実装する方が結果的に早いです。本記事の対象は「ノート・議事録・学習・PDF 注釈」といった汎用ノート系アプリなので、PencilKit が最適解になります。
アーキテクチャ全体像 — Rork で組むときの責務分離
Rork で作るときに最初に決めておきたいのが、SwiftUI レイヤーと UIKit レイヤーの責務分離です。PKCanvasView は UIKit ベースなので、UIViewRepresentable で SwiftUI に橋渡しする必要があります。
私が現在使っている構成は、ざっくり以下の4層です。
- View 層(SwiftUI): ノート一覧、ツールバー、ページサムネイル
- Canvas 層(UIViewRepresentable): PKCanvasView と PKToolPicker の橋渡し
- Document 層(@Observable): PKDrawing と Page の状態管理。Undo/Redo はここに寄せる
- Persistence 層: ローカル(FileManager)と CloudKit を抽象化したリポジトリ
Rork Max にプロンプトを投げるときも、この4層を明示してあげると生成コードがブレません。「PKCanvasView を SwiftUI で扱える CanvasRepresentable を作って、PKDrawing を @Bindable の Document に流し込んでください」という指定の仕方をすると、後段の同期実装まで一貫性が保てます。
ステップ1: PKCanvasView を SwiftUI に統合する
最初の落とし穴は「PKCanvasView を素朴に貼ったら線がカクつく」「ツールピッカーが消えてしまう」という現象です。これはほぼ全員が一度は踏みます。
カクつきの原因は drawingPolicy の既定値で、初期状態は .default(指でも描ける)になっています。指タッチを巻き込んでスクロールジェスチャと衝突するため、Apple Pencil 入力に絞るのが基本です。
ツールピッカーが消えるのは、PKToolPicker を becomeFirstResponder() するタイミングと SwiftUI のレイアウト確定タイミングがずれるためです。updateUIView ではなく makeUIView の中で初期化し、DispatchQueue.main.async で 1 ティック遅らせるのが安定します。
import SwiftUI
import PencilKit
/// SwiftUI から PKCanvasView を扱うラッパー。
/// - Note: drawingPolicy は .pencilOnly に固定。指描きを許可したい場合は外から設定できるよう拡張する。
struct CanvasRepresentable: UIViewRepresentable {
@Binding var drawing: PKDrawing
var isToolPickerVisible: Bool
func makeUIView(context: Context) -> PKCanvasView {
let canvas = PKCanvasView()
canvas.drawing = drawing
canvas.drawingPolicy = .pencilOnly
canvas.alwaysBounceVertical = true
canvas.delegate = context.coordinator
canvas.backgroundColor = .systemBackground
// ツールピッカーは window が確定してから表示する
DispatchQueue.main.async {
guard let window = canvas.window else { return }
let picker = PKToolPicker.shared(for: window) ?? PKToolPicker()
picker.setVisible(isToolPickerVisible, forFirstResponder: canvas)
picker.addObserver(canvas)
canvas.becomeFirstResponder()
}
return canvas
}
func updateUIView(_ canvas: PKCanvasView, context: Context) {
// 外部から差し戻された drawing を反映する(CloudKit 同期で他端末から飛んできた更新など)
if canvas.drawing != drawing {
canvas.drawing = drawing
}
if let window = canvas.window,
let picker = PKToolPicker.shared(for: window) {
picker.setVisible(isToolPickerVisible, forFirstResponder: canvas)
}
}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
/// ストローク追加・削除・編集の通知を Document に戻すための Delegate
final class Coordinator: NSObject, PKCanvasViewDelegate {
var parent: CanvasRepresentable
init(_ parent: CanvasRepresentable) { self.parent = parent }
func canvasViewDrawingDidChange(_ canvasView: PKCanvasView) {
// SwiftUI 側の @State/@Bindable に反映させる
// ここで毎回保存するとパフォーマンスが落ちるので debounce は外側で行う
parent.drawing = canvasView.drawing
}
}
}
このラッパーを使うときは、保存処理を毎ストロークごとに呼ばないようにしてください。私は最初これをやってしまい、長文ノートの後半で 60fps を割るという失敗をしました。canvasViewDrawingDidChange の発火頻度はそれなりに高いので、Document 側で 0.5〜1.0 秒の debounce を挟むのが現実的です。
ステップ2: Apple Pencil Pro の新機能を取り込む
Apple Pencil Pro(第3世代相当)になってから、開発者が拾える入力チャネルが3つ増えました。
- Squeeze: 軸を握り込むジェスチャ。
UIPencilInteraction に .squeeze フェーズで届きます
- Barrel Roll: ペンを軸方向に回転させる。ストローク中の
.azimuth と .altitude が更新されます
- Hover: ペン先が画面に近付いたが触れていない状態。
UIHoverGestureRecognizer で取得します
Squeeze をどう使うかは設計判断ですが、私は「ツールクイック切替(ペン↔消しゴム)」と「ツールピッカー表示トグル」をユーザーが設定で選べるようにしています。Procreate Dreams や純正メモアプリも似た思想で、Squeeze を「現在のコンテキストで一番呼びたい操作」のショートカットに割り当てるのが自然です。
import UIKit
import PencilKit
/// PKCanvasView に取り付けて Squeeze・Hover を扱う Interaction
final class PencilProBridge: NSObject, UIPencilInteractionDelegate {
enum SqueezeAction { case toggleEraser, toggleToolPicker, undo }
weak var canvas: PKCanvasView?
var squeezeAction: SqueezeAction = .toggleEraser
private var savedTool: PKTool?
/// 取り付け先の View に UIPencilInteraction を追加する
func attach(to view: UIView, canvas: PKCanvasView) {
self.canvas = canvas
let interaction = UIPencilInteraction()
interaction.delegate = self
view.addInteraction(interaction)
}
func pencilInteraction(_ interaction: UIPencilInteraction,
didReceiveSqueeze squeeze: UIPencilInteraction.Squeeze) {
// .ended のみ拾うことで誤検出を抑える
guard squeeze.phase == .ended, let canvas = canvas else { return }
switch squeezeAction {
case .toggleEraser:
if canvas.tool is PKEraserTool {
canvas.tool = savedTool ?? PKInkingTool(.pen, color: .label, width: 3)
savedTool = nil
} else {
savedTool = canvas.tool
canvas.tool = PKEraserTool(.bitmap)
}
case .toggleToolPicker:
guard let window = canvas.window,
let picker = PKToolPicker.shared(for: window) else { return }
picker.setVisible(!picker.isVisible, forFirstResponder: canvas)
case .undo:
canvas.undoManager?.undo()
}
}
}
ハプティクス(Squeeze 時のクリック感)はシステム側が出してくれるので、開発者が UIImpactFeedbackGenerator を別途叩く必要はありません。逆に「自分でも振動を返したい」ときは、UIImpactFeedbackGenerator(style: .light) を組み合わせると自然です。
ステップ3: PKDrawing の永続化 — JSON ではなく Data で保存する
ここで多くの方が一度詰まります。PKDrawing を JSON で保存しようとして「パスが配列になっていて変換できない」と気付くパターンです。
PKDrawing には公式の dataRepresentation() があります。これがプラットフォーム間で互換のあるバイナリで、Apple もこれを使うことを推奨しています。逆に独自フォーマットに変換するとバージョンアップでフォーマットが変わったときに移行コストが跳ね上がります。
import Foundation
import PencilKit
/// 1ページ分のノート。複数ページのノートはこれを配列で持つ。
struct NotePage: Identifiable, Codable {
let id: UUID
var drawingData: Data // PKDrawing.dataRepresentation()
var thumbnailPath: String? // ローカル PNG キャッシュへの相対パス
var updatedAt: Date
/// バイナリから PKDrawing を復元する
func drawing() throws -> PKDrawing {
try PKDrawing(data: drawingData)
}
}
/// ローカルのファイルベースで Page を読み書きする最小リポジトリ
struct PageRepository {
let baseURL: URL // 例: Application Support/Notes
func save(_ page: NotePage) throws {
let url = baseURL.appendingPathComponent("\(page.id.uuidString).json")
let data = try JSONEncoder().encode(page)
try data.write(to: url, options: [.atomic])
}
func load(id: UUID) throws -> NotePage {
let url = baseURL.appendingPathComponent("\(id.uuidString).json")
let data = try Data(contentsOf: url)
return try JSONDecoder().decode(NotePage.self, from: data)
}
}
ポイントは2つあります。
1つ目は drawingData を Codable に持たせること。PKDrawing 自体は Codable ではないので、データだけシリアライズして必要なときに復元します。
2つ目は 保存場所を Application Support 以下にすること。Documents 直下に置くと iOS の Files アプリから見えてしまい、ユーザーが誤って削除できてしまいます。バックアップ対象にしたくないキャッシュは Caches、保護したい本体は Application Support というのがガイドラインです。
ステップ4: CloudKit で端末間同期を実装する
iPad で書いたノートが iPhone でも見られる。これは手書きノートアプリでは事実上必須の体験です。
私は Core Data + CloudKit、Realm + Atlas、Supabase など何度か乗り換えましたが、Apple 純正のシンプルさと iCloud 直結のオンボーディング楽さでは CloudKit 直叩きが今も強いと感じます。PKDrawing は数 MB に達することもあるので、レコードに直接埋めるのではなく CKAsset として送るのが正解です。
import CloudKit
import PencilKit
/// CKRecord と NotePage の相互変換 + 1件単位の同期
final class CloudPageSync {
private let container = CKContainer(identifier: "iCloud.com.example.RorkNotes")
private var database: CKDatabase { container.privateCloudDatabase }
/// ローカルの NotePage を CloudKit に push する
func push(_ page: NotePage) async throws {
// CKAsset に変換(一時ファイルが必要)
let tmp = FileManager.default.temporaryDirectory
.appendingPathComponent("\(page.id.uuidString).pkdrawing")
try page.drawingData.write(to: tmp, options: [.atomic])
let recordID = CKRecord.ID(recordName: page.id.uuidString)
let record: CKRecord
do {
record = try await database.record(for: recordID)
} catch let error as CKError where error.code == .unknownItem {
record = CKRecord(recordType: "NotePage", recordID: recordID)
}
record["drawing"] = CKAsset(fileURL: tmp)
record["updatedAt"] = page.updatedAt as NSDate
_ = try await database.save(record)
try? FileManager.default.removeItem(at: tmp)
}
/// 変更を購読する(最初の起動時に1度だけ呼ぶ)
func subscribeToChanges() async throws {
let subscription = CKQuerySubscription(
recordType: "NotePage",
predicate: NSPredicate(value: true),
options: [.firesOnRecordCreation, .firesOnRecordUpdate, .firesOnRecordDeletion]
)
let info = CKSubscription.NotificationInfo()
info.shouldSendContentAvailable = true // サイレントプッシュ
subscription.notificationInfo = info
_ = try await database.save(subscription)
}
}
実装で詰まりがちなのが、「保存即同期」をしてしまうとバッテリーと帯域を食う問題です。私は actor で 5 秒の debounce バッファを持たせ、ストロークが落ち着いたら 1 リクエストにまとめて送るようにしています。
もう1つ重要なのが、競合解決です。同じノートを iPad と iPhone で同時に開いて編集された場合、CloudKit は片方を弾きます。CKError.serverRecordChanged を捕まえて、サーバー側のレコードを取得し、ストローク単位で merge するのが理想です。最初は「最後に書いた方が勝つ(last writer wins)」で良いので、updatedAt の比較で十分です。本格的な merge は PKDrawing.appending(_:) を使えば実装できます。
ステップ5: 手書きを検索可能にする — Vision で OCR を組み込む
ノート系アプリで差別化したいなら、検索可能性は外せません。Apple は iOS 17 以降、VNRecognizeTextRequest の精度が手書きでも実用域に入ってきました。
ストロークのラスタライズ → VNRecognizeTextRequest → 認識結果を Page と一緒にインデックス、というパイプラインを組みます。注意点は、OCR をユーザー操作の同期パスに置かないことです。バックグラウンドで動かして、検索インデックスだけ更新します。
import Vision
import PencilKit
import UIKit
/// PKDrawing から OCR テキストを抽出する
struct HandwritingOCR {
/// 1ページぶんの drawing を 1024px 幅の画像にして OCR にかける
static func recognize(_ drawing: PKDrawing) async throws -> [String] {
let bounds = drawing.bounds.isEmpty
? CGRect(x: 0, y: 0, width: 1024, height: 1024)
: drawing.bounds
let scale = 1024.0 / max(bounds.width, 1)
let image = drawing.image(from: bounds, scale: scale)
guard let cgImage = image.cgImage else { return [] }
let request = VNRecognizeTextRequest()
request.recognitionLevel = .accurate
request.usesLanguageCorrection = true
request.recognitionLanguages = ["ja-JP", "en-US"]
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
try handler.perform([request])
let observations = request.results ?? []
return observations.compactMap { $0.topCandidates(1).first?.string }
}
}
精度が物足りないと感じたら、recognitionLevel = .accurate のままで OK ですが、1ページ全体ではなくストロークの密集領域(クラスタ)ごとに分割して投げると劇的に改善します。Vision は局所的な行検出に強い反面、広い余白を含む画像が苦手です。クラスタ分割は PKStroke.path の bounding box を 80px 程度の閾値で grouping すれば実装できます。
検索 UI 側では、抽出テキストを SQLite + FTS5 か Core Data の NSPersistentStoreDescription の Spotlight 統合で索引化すると、Spotlight 経由でホーム画面検索からも引けるようになります。これも純正メモアプリで採用されている設計で、ユーザーの「ノートを横断して検索したい」期待に応えられます。
ステップ6: PDF 注釈モードを追加する
ビジネスユーザー向けに最も求められるのが PDF 注釈です。PencilKit はこれを PDFView 上に重ねるだけで実装できます。
import PDFKit
import PencilKit
/// PDF の各ページに対して PKDrawing を重ねる
final class AnnotatedPDFController {
private let pdfView: PDFView
private var canvases: [Int: PKCanvasView] = [:]
init(pdfView: PDFView) { self.pdfView = pdfView }
/// 指定ページに PencilKit のキャンバスを重ねる
func attachCanvas(at pageIndex: Int) {
guard let page = pdfView.document?.page(at: pageIndex) else { return }
let pageBounds = pdfView.convert(page.bounds(for: .mediaBox), from: page)
let canvas = PKCanvasView(frame: pageBounds)
canvas.drawingPolicy = .pencilOnly
canvas.backgroundColor = .clear
canvas.isOpaque = false
pdfView.addSubview(canvas)
canvases[pageIndex] = canvas
}
/// 注釈を焼き込んだ PDF を書き出す
func exportFlattened(to url: URL) throws {
guard let document = pdfView.document else {
throw NSError(domain: "AnnotatedPDF", code: -1)
}
for index in 0..<document.pageCount {
guard let page = document.page(at: index),
let canvas = canvases[index] else { continue }
let image = canvas.drawing.image(from: canvas.bounds, scale: UIScreen.main.scale)
let annotation = PDFAnnotation(bounds: page.bounds(for: .mediaBox),
forType: .stamp,
withProperties: nil)
annotation.image = image
page.addAnnotation(annotation)
}
document.write(to: url)
}
}
書き出した PDF はそのまま AirDrop でも、Mail 添付でも、UIActivityViewController でも配れます。私のアプリでは「無料は閲覧のみ、注釈付き書き出しは Pro 限定」にしています。これがいちばんサブスク転換率が良かった機能です。
よくある間違い・落とし穴
ここまで実装してくると、ほぼ確実に踏む地雷がいくつかあります。先に挙げておきます。
1. PKToolPicker がシミュレータで表示されない
これは仕様で、シミュレータには Apple Pencil 入力が無いため becomeFirstResponder だけでは表示されません。実機で必ず確認してください。シミュレータで動かしたい場合は drawingPolicy = .anyInput に一時的に変えると指タッチで描画でき、ツールピッカーも出ます。
2. iPad 横画面で PKCanvasView のスクロール領域がズレる
PKCanvasView はスクロールビューを内包しているので、Auto Layout で外側に Stack を組むと描画領域とスクロール領域がズレます。SwiftUI では .ignoresSafeArea(edges: .bottom) を CanvasRepresentable に付けて、ツールピッカーの分の余白だけ別途管理するのが安定します。
3. CloudKit のスキーマを本番アップデートで壊してしまう
CloudKit は開発環境(Development)と本番環境(Production)でスキーマが分かれており、CKRecord のフィールドを削除・型変更すると古い端末がクラッシュします。本番に出した後はフィールド追加のみ、削除や型変更は新フィールド + マイグレーションで対応するのが鉄則です。私はこれで一度本番ユーザーのデータを消しかけて、慌てて全員に「アプリを更新してください」と通知を送る羽目になりました。
4. Apple Pencil Pro の機能が第2世代でも動くと勘違いする
Squeeze と Barrel Roll は Pencil Pro 専用です。第2世代で UIPencilInteraction.didReceiveSqueeze を呼んでも何も起きません。PKToolPicker のツール一覧で「Squeeze に割り当て」UI を出すときは、世代判定が必要です。UIPencilInteraction の prefersPencilOnlyDrawing プロパティを使えば、現在接続中のペンの能力を取得できます。
5. PKDrawing.image() がメモリを食い潰す
A4 ページ全面に細かいストロークを描いた状態で image(from:scale:) を UIScreen.main.scale(@3x)で呼ぶと、1 ページで 100MB 超えのメモリを食うことがあります。サムネイル用途なら scale を 0.5 や 0.25 に下げる、エクスポート用途でも一度に 1 ページずつしか処理しない、autoreleasepool で囲むなどの対策が必要です。
パフォーマンス — 長文ノートでも 60fps を維持する
手書きアプリが ★2 のレビューを受ける最大の原因は「長く書くと重くなる」です。原因はほぼ次の3つに絞れて、いずれも事前に防げます。
1つ目は、ページサムネイルをメインスレッドで生成してしまうことです。スクロール中に PKDrawing.image() を同期で呼ぶと UI がカクつきます。サムネイル生成はバックグラウンド Task に逃がし、結果はディスクにキャッシュ。再生成はページ更新時のみで十分です。
2つ目は、CloudKit の debounce 設計です。バッファが短すぎる(200ms など)と、ユーザーが文を書いている最中に何度もリクエストが飛びます。逆に長すぎる(60 秒)とアプリを閉じる前に同期が走りません。私のアプリでは 5 秒が落としどころで、applicationWillResignActive で保留中の push を flush するのが定石です。
3つ目は、SwiftUI の再レンダリングごとに PKCanvasView が作り直されることです。CanvasRepresentable の親が頻繁に再描画されると、SwiftUI が UIKit の View 自体を作り直す判断をすることがあります。開発中は makeUIView の発火回数を監視してください — ページロードあたり 1 回が正解です。
import os.signpost
extension OSLog {
static let canvas = OSLog(subsystem: "com.example.RorkNotes", category: "Canvas")
}
func makeUIView(context: Context) -> PKCanvasView {
os_signpost(.event, log: .canvas, name: "PKCanvasView created")
// ...残りの初期化
}
このシグネポストを 1 度仕込んでおいて、Instruments の Points of Interest で観察します。スクロール中や別フィールドへの入力中に何度も発火していたら、親 View が過剰に invalidate されている合図です。
接続中の Pencil 世代を判定する
Pencil Pro の機能は Pencil Pro が接続されているときだけ意味があります。Pencil 第2世代の利用者に「Squeeze の動作を選ぶ」設定を見せると壊れた印象を与えます。
判定はすこし回りくどく、Apple は「接続中の Pencil の型番」を直接返す API を出していません。代わりに UIPencilInteraction の能力フラグや、ストローク中に飛んでくるタッチメタデータから推測します。
import UIKit
enum PencilGeneration {
case unknown, firstGen, secondGen, usbC, pro
}
final class PencilDetector {
private(set) var generation: PencilGeneration = .unknown
func update(from interaction: UIPencilInteraction) {
// Squeeze 対応は Pro のみ
if #available(iOS 17.5, *), interaction.prefersSqueeze {
generation = .pro
return
}
// ダブルタップは第2世代と Pro が対応。USB-C 版は非対応
if interaction.prefersTap {
generation = .secondGen
} else {
generation = .usbC
}
}
}
意図的に保守的に判定しています。世代に依存して必ずゲートしたい機能は Squeeze だけなので、「Squeeze が使える= Pro として扱う」で十分です。設定 UI 側では generation == .pro を見て Squeeze 関連の項目を出し分けます。
手書きフローのテスト戦略
手書きの UI テストはタップ操作のテストよりも難しいですが、丸ごと諦める必要はありません。コツは、PKCanvasView を介さずに Document 層 — PKDrawing を保持して保存する部分 — を独立してテストすることです。
import XCTest
import PencilKit
@testable import RorkNotes
final class NotePageTests: XCTestCase {
func testRoundTripSerialization() throws {
let stroke = PKStroke(
ink: PKInk(.pen, color: .black),
path: PKStrokePath(controlPoints: [
PKStrokePoint(location: CGPoint(x: 0, y: 0),
timeOffset: 0,
size: CGSize(width: 3, height: 3),
opacity: 1, force: 1, azimuth: 0, altitude: .pi / 2)
], creationDate: Date())
)
let drawing = PKDrawing(strokes: [stroke])
let page = NotePage(id: UUID(),
drawingData: drawing.dataRepresentation(),
thumbnailPath: nil,
updatedAt: Date())
let restored = try page.drawing()
XCTAssertEqual(restored.strokes.count, 1)
}
}
これだけで、シリアライズ破綻・OS アップデートに伴うパスのエンコーディング変更・Codable 適合がフィールド追加で外れる、といった「ほぼ全ての本当に困る回帰」を捕まえられます。私は CI で毎回走らせていて、これまで 2 回ほど「データ破損になりかけた」事故をすり抜けで防いでくれました。
Rork でこの規模を組むときのプロンプト運用
これは 1 発のプロンプトで終わるサイズではありません。手書きアプリは触るサブシステムが多く、数日はプロンプトループの中で過ごすことになります。プロンプトの組み立て方で、出てくるコードの清潔さがかなり変わります。
私が安定して使えているのは、1 プロンプト = 1 レイヤーの縛りです。最初は SwiftUI シェル + 空の CanvasRepresentable、次に Pencil Pro ブリッジ、次に永続化、次に CloudKit、最後に OCR。各プロンプトは関心事を 1 つだけに絞ります。
生成コードがブレる最大の原因は「2 つを同時に頼んだとき」です。「CloudKit 同期と検索バーを足して」と頼むと、Rork は両者の関心事をマージしがちで、後でリファクタするのが大変になります。少し我慢して 1 レイヤーずつ進める方が結果的に速いです。
もう 1 つ効くのが、プロンプト先頭にコンテキストプリフェイスを毎回付けることです。「このアプリは何で、何が既にあって、いま何を追加したいか」を 4〜6 行でまとめたブロックを毎回貼り直します。「PencilKit を使う(自前 Metal ではない)」「actor ベースの永続化(Core Data ではない)」「CloudKit(Firebase ではない)」を毎回宣言しておくと、モデルがそれを忘れません。トークンの代償より、一貫性のリターンの方が大きいです。
収益化への接続 — Pro サブスクの自然な導線
ここまで実装すると、無料/Pro の機能境界をどう引くかという話になります。私の経験上、手書きノート系で転換率が高い境界線は次の3つです。
- ノート数の上限: 無料は 5 ノート、Pro で無制限
- PDF エクスポート: 注釈付き PDF の書き出しを Pro 限定にする
- OCR 検索: 手書き検索を Pro 限定にする
ここで重要なのは、無料ユーザーにも「これ便利」と感じる十分な体験を提供することです。例えば「3 ノート目を作ろうとしたらいきなりペイウォール」だと、レビューが荒れます。私は「使い始めて 7 日後 + 3 ノート以上書いた人にだけ、初めて控えめに案内する」という設計に落ち着きました。
サブスクの実装そのものは StoreKit 2 が現代では一番楽です。実装の詳細は Rork × StoreKit 2 で課金を実装する完全ガイド を参照してください。CloudKit 連動の購入状態同期については RevenueCat × Stripe Web で会員サブスクをまたがって扱う も役に立ちます。
全体を振り返って — 今日からできる最初の一歩
PencilKit は「貼るだけで動く」ところから「App Store で勝負できる手書きアプリ」までの距離が、適切な設計を知っていれば大幅に縮まります。
最初の一歩としておすすめなのは、本記事の「ステップ1」の CanvasRepresentable をそのまま Rork のプロジェクトに貼り付けて、Apple Pencil で実機で動かしてみることです。PKToolPicker が表示されて、ストロークが流れる感触が手元で確認できれば、残りのステップは段階的に積み上げていけます。
そこから OCR、PDF 注釈、CloudKit 同期と進めていけば、3〜4 週間で App Store に出せる完成度に到達できるはずです。完成したアプリのレビューが届き始めると、不思議と「このノートに線を引いて意見を書いてくれた人がいる」という感覚が湧いて、開発のモチベーションが続きます。私自身はそれが楽しくて、もう何年もこの分野を触り続けています。