0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Swift + Vision Framework + Realmを使ってみた

Last updated at Posted at 2025-03-01

目次

1. 背景
2. 今回開発するアプリ
3. アプリ解説
3-1. ステップ1_ボタンを表示
3-2. ステップ2_ボタンクリックで画面遷移
3-3. ステップ3_カメラロールから写真を選択
3-4. ステップ4_カメラロールから写真を選択かつその写真にある文字を識別し表示
3-5. ステップ5_認識した文字列をローカルデータベースに保存及び取得
4. 終わりに

背景

現在、Qiitaの別記事で初めてのアプリ開発として要件定義からテストまでを勉強しながら経験しようとしています。
この記事では要件定義の技術選定として、モバイルアプリ開発言語のSwift、画像解析が行えるVision.Framework、ローカルデータベースのRealmを使った簡単なアプリが作成できるか試そうとしています。
この記事で要件定義について勉強した内容をまとめています。

今回開発するアプリ

iPhoneのカメラロールにある写真から文章を見つけて、データベースへ保存する。
その後、そのデータを閲覧できるアプリ。

アプリ解説

Swiftアプリ開発が今回で初めてだった為、ゴールに向かう道中の段階をステップとして分けて記録していきます。

ステップ1_ボタンを表示

ContentView.swift
struct StartView: View {
    var body: some View {
        VStack {
            Button(action: {
                
            }) {
                Text("データ取得")
            }
            .padding()
            .accentColor(Color.white)
            .background(Color.blue)
 
            Button(action: {
                
            }) {
                Text("データ閲覧")
            }
            .padding()
            .accentColor(Color.white)
            .background(Color.blue)
        }
    }
}

ポイント

  • VStackで囲むと、その中の子となるView(今回は2つのボタン)が、縦並びになる
  • VStackの他に、HStack(横並び)とZStack(重ね並び)がある
  • bodyはViewの見た目を定義するもので、その型は基本的にsome Viewが使われる
    • someの他に、プロトコルとして使用するanyや、ジェネリクスを使うパターンがあるそう

ステップ2_ボタンクリックで画面遷移

ContentView.swift
import SwiftUI

struct ContentView: View {
    // GetImageView遷移フラグ
    @State private var isShowingGetImageView = false
    
    // BrowseSentenceView遷移フラグ
    @State private var isShowingBrowseSentenceView = false

    var body: some View {
        NavigationStack{
            VStack {
                Button(action: {
                    // GetImageView遷移フラグをON
                    isShowingGetImageView = true
                }) {
                    Text("データ取得")
                        .padding()
                        .accentColor(Color.white)
                        .background(Color.blue)
                }
                // [データ取得]ボタンが押下されたらGetImageViewへ遷移
                .navigationDestination(isPresented: $isShowingGetImageView) {
                    GetImageView()
                }

                Button(action: {
                    // BrowseSentenceView遷移フラグをON
                    isShowingBrowseSentenceView = true
                }) {
                    Text("データ閲覧")
                        .padding()
                        .accentColor(Color.white)
                        .background(Color.blue)
                }
                // [データ閲覧]ボタンが押下されたらBrowseSentenceViewへ遷移
                .navigationDestination(isPresented: $isShowingBrowseSentenceView) {
                    BrowseSentenceView()
                }
            }
        }
    }
}

struct GetImageView: View {
    var body: some View {
        Text("データ取得画面")
    }
}

struct BrowseSentenceView: View {
    var body: some View {
        Text("データ閲覧画面")
    }
}

ポイント

  • 画面遷移を実現するために、navigationDestinationを採用
  • NavigationStack内でnavigationDestinationを実装することで、画面遷移することができる
  • ボタンをクリックすると対応するフラグをtureに変更し、isPresentedでフラグがtrueになるのを確認したら次のViewに遷移している
  • このようにViewで状態管理をするときには変数宣言時にStateをつけて使用すると、変数の値が変化したら自動でViewが再描画される

ステップ3_カメラロールから写真を選択

ContentView.swift
import SwiftUI
import PhotosUI

struct ContentView: View {
    // GetImageView遷移フラグ
    @State private var isShowingGetImageView = false
    
    // BrowseSentenceView遷移フラグ
    @State private var isShowingBrowseSentenceView = false

    var body: some View {
        NavigationStack{
            VStack {
                Button(action: {
                    // GetImageView遷移フラグをON
                    isShowingGetImageView = true
                }) {
                    Text("データ取得")
                        .padding()
                        .accentColor(Color.white)
                        .background(Color.blue)
                }
                // [データ取得]ボタンが押下されたらGetImageViewへ遷移
                .navigationDestination(isPresented: $isShowingGetImageView) {
                    GetImageView()
                }

                Button(action: {
                    // BrowseSentenceView遷移フラグをON
                    isShowingBrowseSentenceView = true
                }) {
                    Text("データ閲覧")
                        .padding()
                        .accentColor(Color.white)
                        .background(Color.blue)
                }
                // [データ閲覧]ボタンが押下されたらBrowseSentenceViewへ遷移
                .navigationDestination(isPresented: $isShowingBrowseSentenceView) {
                    BrowseSentenceView()
                }
            }
        }
    }
}

struct GetImageView: View {
    // 選択された画像ファイル
    // 値が存在する状態と存在しない状態の両方を扱う為の変数(オプショナル変数)
    @State private var selectedImage: UIImage? = nil
    
    // 写真選択処理実行フラグ
    @State private var isShowingSelectPicture = false
    
    
    var body: some View {
        VStack {
            // selectedImage変数に値(画像)が設定されているか確認
            // オプショナル変数をアンラップしている(オプショナルバインディング)
            if let image = selectedImage {
                // 選択された画像を表示
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(width: 200, height: 200)
                    .border(Color.gray, width: 1)
            } else {
                Text("画像が選択されていません")
                    .foregroundColor(.gray)
            }
            
            Button(action: {
                // 写真選択処理実行フラグをON
                isShowingSelectPicture = true
            }) {
                Text("写真を選択")
                    .padding()
                    .accentColor(Color.white)
                    .background(Color.blue)
            }
        }
        // 「写真を選択」ボタンが押下された時にSelectPictureを呼び出す
        // モーダル画面を開きそこでSelectPictureを実行
        .sheet(isPresented: $isShowingSelectPicture) {
            SelectPicture(selectedImage: $selectedImage)
        }
    }
}

struct BrowseSentenceView: View {
    var body: some View {
        Text("データ閲覧画面")
    }
}
ImageAnalysis.swift
import SwiftUI
import PhotosUI
import UIKit

struct SelectPicture: UIViewControllerRepresentable{
    
    // 選択した画像ファイル
    @Binding var selectedImage: UIImage?

    // 写真を選択できるようにPHPickerViewコントローラーを作成
    func makeUIViewController(context: Context) -> PHPickerViewController {
        // PHPickerViewControllerの動作や設定する為のインスタンスを生成
        var config = PHPickerConfiguration()
        
        // 画像のみを選択可能にする
        config.filter = .images
        
        // メディアの選択最大数を1つに設定
        config.selectionLimit = 1
        
        // 写真や動画を選択する為の標準Viewコントローラーの生成
        let picker = PHPickerViewController(configuration: config)
        
        // UIKitの操作結果をSwiftUIで扱えるようにする
        picker.delegate = context.coordinator
        return picker
    }

    // 状態変化に伴う処理は必要ないので空実装
    func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}

    // 写真を選択してSwiftUIへ連携する為のクラスを呼び出す
    func makeCoordinator() -> Coordinator {
        Coordinator(self)
    }

    // PHPickerViewControllerDelegateでメディア(写真や動画)を取得する為のクラス
    // デリゲートを扱うためにNSObjectを継承する必要あり
    class Coordinator: NSObject, PHPickerViewControllerDelegate {
        // SelectPictureのプロパティ(変数やメソッド)にアクセスする為の変数
        let parent: SelectPicture

        init(_ parent: SelectPicture) {
            self.parent = parent
        }

        // PHPickerViewControllerDelegateプロトコルからの実装
        // ユーザがメディアを選択したときに呼び出されるメソッド
        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            // ピッカーを閉じる
            picker.dismiss(animated: true)

            // 選択した画像を取得
            // 画像選択画面で何も選択されたなった場合は処理終了
            guard let result = results.first else { return }
            
            // 選択したファイルが画像であるか判定
            if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
                // 画像ファイルを取得してimage変数に格納
                result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in
                    // 非同期処理で画像を表示するようにselectedImage変数に取得した画像ファイルを格納
                    DispatchQueue.main.async {
                        if let uiImage = image as? UIImage {
                            self.parent.selectedImage = uiImage
                        }
                    }
                }
            }
        }
    }
}

ポイント

  • データ取得画面(GetImageView)では「写真を選択ボタンを押す」と「カメラロールから写真を1枚選択」して「選択した写真を表示」をしている
  • 「写真を選択」する処理と、「写真を表示」する処理は別々のクラス(View)で実施することになる
    • 画面フロー上の親となるのが今回はGetImageView、子となるのがSelectPicture
    • GetImageViewからSelectPicture呼び出し時に、データ共有用の変数(selectedImage)を渡している
    • SelectPicture(selectedImage: $selectedImage)
    • コロン(:)の前がSelectPictureクラスでの変数名、コロンの後がGetImageViewでの変数名
    • State変数は親向け、Binding変数は子向け
  • 画像ファイルを管理する変数は、今回オプショナル変数(末尾に「?」)としている
    • オプショナル変数とは、その変数を利用するタイミングで、値を保持するか保持しない(nil)か分からない変数
    • もしオプショナル変数を利用せずにnilとなる変数を利用すると、エラーもしくはクラッシュしてしまう
    • 更に安全にオプショナル変数を利用する為に、「if let」や「guard let」を使用してnilチェックをした後、変数から値を取り出すようにする
  • カメラロールから写真を選択する処理はUIKitPHPickerViewControllerを使用している。またUIKitで扱ったデータはSwiftUIではそのまま使用できないので、同じくUIKitのUIViewControllerRepresentableを使用し、SwiftUIと連携して使用している
UIViewControllerRepresentable.swift

func makeUIViewController(context: Context) -> UIViewControllerType{
  // 【実装必須】Viewコントローラーオブジェクトを作成し、初期状態を構成して返却するメソッド
  // 最初にViewコントローラーオブジェクトを作成する時にのみ呼び出される
  // 今回、写真選択処理で使用するPHPickerViewControllerを作成して返却している
}

func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) {
  // 【実装必須】Viewコントローラーオブジェクトを更新するときに使用するメソッド
  // 状態の変化が発生したときに呼び出したりなど
  // 今回は状態変化に伴う処理は必要なかったので定義のみ実装し処理は未実装
}

func dismantleUIViewController(_ uiViewController: UIViewControllerType, coordinator: Coordinator) {
  // 【実装任意】Viewコントローラーオブジェクトを削除する前の後処理やクリーンアップをするときに使用するメソッド
  // リソースの解放や、オブザーバー(オブジェクトの状態監視)の解除、一時データの保存などを実装する
  // 今回は未実装
}

func makeCoordinator() -> Coordinator {
  // 【実装必須】ViewコントローラーからSwiftUI等他アプリへイベントやデータなどを連携する為のカスタムインスタンスを作成するメソッド
  // 他のアプリへの連携が必要ないときは定義のみをして未実装でも大丈夫な様子
  // 今回はViewコントローラーで写真を取得してSwiftUIへその写真を連携している
}
  • (UIKitで)画像ファイルを取得して、(SwiftUIで)画像ファイルを表示するような、UIKitとSwiftUIを連携する為にカスタムクラス(Coordinatorクラス)を用意
  • UIの更新(ラベルの変更/画像差し替え/データのリロード等)は、メインスレッドから実行しないと最悪クラッシュする可能性があるので、DispatchQueue.main.async にてメインスレッドで処理するようになっている

ステップ4_カメラロールから写真を選択かつその写真にある文字を識別し表示

StartView.swift
import SwiftUI

struct StartView: View {
    // GetImageView遷移フラグ
    @State private var isShowingGetImageView = false
    
    // BrowseSentenceView遷移フラグ
    @State private var isShowingBrowseSentenceView = false

    var body: some View {
        NavigationStack{
            VStack {
                Button(action: {
                    // GetImageView遷移フラグをON
                    isShowingGetImageView = true
                }) {
                    Text("データ取得")
                        .padding()
                        .accentColor(Color.white)
                        .background(Color.blue)
                }
                // [データ取得]ボタンが押下されたらGetImageViewへ遷移
                .navigationDestination(isPresented: $isShowingGetImageView) {
                    GetImageView()
                }

                Button(action: {
                    // BrowseSentenceView遷移フラグをON
                    isShowingBrowseSentenceView = true
                }) {
                    Text("データ閲覧")
                        .padding()
                        .accentColor(Color.white)
                        .background(Color.blue)
                }
                // [データ閲覧]ボタンが押下されたらBrowseSentenceViewへ遷移
                .navigationDestination(isPresented: $isShowingBrowseSentenceView) {
                    BrowseSentenceView()
                }
            }
        }
    }
}
GetImageView.swift
import SwiftUI

struct GetImageView: View {
    // TextRecognitionクラスのインスタンスを作成
    // このインスタンスを子View/structに共有して使用する
    @StateObject private var imageProcessor = TextRecognition()
    
    // 写真選択処理実行フラグ
    @State private var isShowingSelectPicture = false
    
    // 文字識別結果表示処理実行フラグ
    @State private var isShowingTextRecognitionView = false
    
    
    var body: some View {
        VStack {
            Button(action: {
                // 写真選択処理実行フラグをON
                isShowingSelectPicture = true
            }) {
                Text("写真を選択")
                    .padding()
                    .accentColor(Color.white)
                    .background(Color.blue)
            }
            // 「写真を選択」ボタンが押下された時にSelectPictureを呼び出す
            // モーダル画面を開きそこでSelectPictureを実行
            .sheet(isPresented: $isShowingSelectPicture) {
                SelectPicture(imageProcessor: imageProcessor)
            }
            
            // 画像が選択されたら次の画面へ遷移
            .navigationDestination(isPresented: $isShowingTextRecognitionView) {
                TextRecognitionView(imageProcessor: imageProcessor)
            }
            // imageProcessor.selectedImageに値が入った(値が変更された)タイミングで実行
            // (oldValue:変化前, newValue:変化後)
            .onChange(of: imageProcessor.selectedImage) { oldValue, newValue in
                if newValue != nil {
                    // 文字識別結果表示処理実行フラグをON
                    isShowingTextRecognitionView = true
                }
            }
        }
    }
}
SelectPicture.swift
import SwiftUI
import PhotosUI
import UIKit

struct SelectPicture: UIViewControllerRepresentable{
    // 親View(GetImageView)から共有されたTextRecognitionインスタンス変数
    @ObservedObject var imageProcessor: TextRecognition

    func makeUIViewController(context: Context) -> PHPickerViewController {
        // PHPickerViewControllerの動作や設定する為のインスタンスを生成
        var config = PHPickerConfiguration()
        
        // 画像のみを選択可能にする
        config.filter = .images
        
        // メディアの選択最大数を1つに設定
        config.selectionLimit = 1
        
        // 写真や動画を選択する為の標準Viewコントローラーの生成
        let picker = PHPickerViewController(configuration: config)
        
        // UIKitの操作結果をSwiftUIで扱えるようにする
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(imageProcessor: imageProcessor)
    }

    // PHPickerViewControllerDelegateでメディア(写真や動画)を取得する為のクラス
    // デリゲートを扱うためにNSObjectを継承する必要あり
    class Coordinator: NSObject, PHPickerViewControllerDelegate {
        let imageProcessor: TextRecognition

        init(imageProcessor: TextRecognition) {
            self.imageProcessor = imageProcessor
        }

        // PHPickerViewControllerDelegateプロトコルからの実装
        // ユーザがメディアを選択したときに呼び出されるメソッド
        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            // ピッカーを閉じる
            picker.dismiss(animated: true)

            // 選択した画像を取得
            // 画像選択画面で何も選択されたなった場合は処理終了
            guard let result = results.first else { return }
            
            // 選択したファイルが画像であるか判定
            if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
                // 画像ファイルを取得してimage変数に格納
                result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in
                    // 非同期処理でselectedImage変数に取得した画像ファイルを格納し、
                    // 文字識別メソッドを実行
                    DispatchQueue.main.async {
                        if let uiImage = image as? UIImage {
                            self.imageProcessor.selectedImage = uiImage
                            self.imageProcessor.recognizeText(from: uiImage)
                        }
                    }
                }
            }
        }
    }
}
TextRecognition.swift
import SwiftUI
import Vision

class TextRecognition : ObservableObject{
    // 選択された画像ファイルを格納する変数
    @Published var selectedImage: UIImage?
    
    // 識別された画像内文字列を格納する変数
    @Published var extractedText: String = ""

    // 画像解析メソッド
    func recognizeText(from image: UIImage) {
        // 画像処理向けのピクセルデータが取得できなかった場合、処理失敗とみなす
        guard let cgImage = image.cgImage else {
            extractedText = "画像の変換に失敗しました"
            return
        }

        // Visionのリクエストを作成
        // ここでどんな識別をしたいのかを定義する
        // resultRequest:テキスト認識結果格納変数
        // error:テキスト認識結果エラー格納変数
        let request = VNRecognizeTextRequest { (resultRequest, error) in
            // エラーが発生した場合、解析結果を「エラー:<具体的なエラーメッセージ>」とする
            // エラー発生後、すぐにreturnするのでスレッドを気にする必要がない為、「DispatchQueue.main.async」は未使用
            if let error = error {
                self.extractedText = "エラー: \(error.localizedDescription)"
                return
            }

            // 結果を取得
            // resultRequestからテキスト認識結果(VNRecognizedTextObservationの配列)を取得
            if let observations = resultRequest.results as? [VNRecognizedTextObservation] {
                // observations配列の各要素(compactMapでnilな要素は除く)に対して、
                let recognizedStrings = observations.compactMap { observation in
                    // 信頼度が1番高い認識結果をrecognizedStrings配列変数に格納(もし無ければnil)
                    observation.topCandidates(1).first?.string
                }
                
                // 非同期処理で画面表示用変数の更新
                DispatchQueue.main.async {
                    // 文字列認識結果配列から、各要素を取り出し改行(¥n)で結合し、画面表示用変数を更新
                    self.extractedText = recognizedStrings.joined(separator: "\n")
                }
            // もしVNRecognizedTextObservationの配列が取得できなかった場合
            } else {
                // 非同期処理で画面表示用変数の更新
                DispatchQueue.main.async {
                    self.extractedText = "テキストが検出されませんでした"
                }
            }
        }
        
        // 文字連式の認識レベルの設定
        // accurate:処理速度は遅いが、精度は高い
        // fast:処理速度は速いが、精度は低い
        request.recognitionLevel = .accurate

        // 識別対象を日本語に設定
        request.recognitionLanguages = ["ja"]

        // リクエストハンドラの作成
        // cgImage:文字識別対象画像
        // options:文字識別時のオプション指定(今回は指定無し)
        let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
        
        // バックグラウンドスレッドで処理を実施
        // qos:実行優先度を指定(userInitiated:ユーザの入力に応じて実行される処理向け)
        DispatchQueue.global(qos: .userInitiated).async {
            // エラーハンドリングをする為に、do-catch構文を使用
            do {
                // VNRecognizeTextRequestを実行
                try handler.perform([request])
            } catch {
                // もしエラーが発生した場合
                // 非同期処理で画面表示用変数の更新
                DispatchQueue.main.async {
                    self.extractedText = "リクエストの実行に失敗しました: \(error.localizedDescription)"
                }
            }
        }
    }
}
TextRecognitionView.swift
import SwiftUI

struct TextRecognitionView: View {
    // TextRecognitionクラスのインスタンスを作成
    // TextRecognitionから文字認識結果を参照できるようにする
    @ObservedObject var imageProcessor: TextRecognition

    var body: some View {
        VStack {
            Text("抽出されたテキスト:")
                .font(.headline)
                .padding(.top)
            ScrollView {
                Text(imageProcessor.extractedText)
                    .padding()
                    .background(Color.gray.opacity(0.2))
                    .cornerRadius(8)
            }
            .frame(height: 200)
        }
    }
}

ポイント

  • Viewがどんどん増えてきたので、View毎にファイルを分けて管理するように改修
    • StartView.swift:アプリ起動時の初期画面
    • GetImageView.swift:カメラロールから写真を選択する画面
    • TextRecognitionView.swift:文字認識結果を表示及び保存可否を選択する画面
  • NavigationStackとNavigationLink/navigationDestinationの組み合わせが2つ以上ある場合、画面がネストしてしまい、「Back」が2つ以上表示されてしまう
    • 改善点として、NavigationStackを画面遷移を行う初めのViewでのみ定義することで、以降のNavigationLink/navigationDestinationで遷移するときに画面がネストしなくなる
  • 複数のViewやclassにて画像データや文字認識結果を共有する必要が出てきた。今回これを実現するために「ObservableObject」を使用する
    • 「State」と「Binding」による共有は、1対1の共有でのみ使用するので今回不採用
    • ObservableObjectを継承したクラス(TextRecognition)で共有する変数に対して「Published」を設定することにより、他のViewで、ObservableObjectを継承したクラスのインスタンスを作成することで、それ経由で変数にアクセスすることができる
    • 複数のView間でObservableObjectを共有するため、初めてViewでインスタンスを作成し使用する変数には「StateObject」
    • 以降のStateObjectを参照し使用するインスタンス変数には「ObservedObject」
    • 別のViewに遷移する時「HogeView(引数名: 渡すインスタンス変数名)」と実装する

ステップ5_認識した文字列をローカルデータベースに保存及び取得

この記事における完成品のソース

ScanNoteApp.swift
import SwiftUI

@main
struct ScanNoteApp: App {
    var body: some Scene {
        WindowGroup {
            StartView()
        }
    }
}
StartView.swift
import SwiftUI

struct StartView: View {
    // GetImageView遷移フラグ
    @State private var isShowingGetImageView = false
    
    // BrowseSentenceView遷移フラグ
    @State private var isShowingBrowseSentenceView = false

    var body: some View {
        NavigationStack{
            VStack {
                Button(action: {
                    // GetImageView遷移フラグをON
                    isShowingGetImageView = true
                }) {
                    Text("データ取得")
                        .padding()
                        .accentColor(Color.white)
                        .background(Color.blue)
                }
                // [データ取得]ボタンが押下されたらGetImageViewへ遷移
                .navigationDestination(isPresented: $isShowingGetImageView) {
                    GetImageView()
                }

                Button(action: {
                    // BrowseSentenceView遷移フラグをON
                    isShowingBrowseSentenceView = true
                }) {
                    Text("データ閲覧")
                        .padding()
                        .accentColor(Color.white)
                        .background(Color.blue)
                }
                // [データ閲覧]ボタンが押下されたらBrowseSentenceViewへ遷移
                .navigationDestination(isPresented: $isShowingBrowseSentenceView) {
                    BrowseSentenceView()
                }
            }
        }
    }
}
GetImageView.swift
import SwiftUI

struct GetImageView: View {
    // TextRecognitionクラスのインスタンスを作成
    // このインスタンスを子View/structに共有して使用する
    @StateObject private var imageProcessor = TextRecognition()
    
    // 写真選択処理実行フラグ
    @State private var isShowingSelectPicture = false
    
    // 文字識別結果表示処理実行フラグ
    @State private var isShowingTextRecognitionView = false
    
    
    var body: some View {
        VStack {
            Button(action: {
                // 写真選択処理実行フラグをON
                isShowingSelectPicture = true
            }) {
                Text("写真を選択")
                    .padding()
                    .accentColor(Color.white)
                    .background(Color.blue)
            }
            // 「写真を選択」ボタンが押下された時にSelectPictureを呼び出す
            // モーダル画面を開きそこでSelectPictureを実行
            .sheet(isPresented: $isShowingSelectPicture) {
                SelectPicture(imageProcessor: imageProcessor)
            }
            // 画像が選択されたら次の画面へ遷移
            .navigationDestination(isPresented: $isShowingTextRecognitionView) {
                TextRecognitionView(imageProcessor: imageProcessor)
            }
            // imageProcessor.selectedImageに値が入った(値が変更された)タイミングで実行
            // (oldValue:変化前, newValue:変化後)
            .onChange(of: imageProcessor.selectedImage) { oldValue, newValue in
                if newValue != nil {
                    // 文字識別結果表示処理実行フラグをON
                    isShowingTextRecognitionView = true
                }
            }
        }
    }
}
SelectPicture.swift
import SwiftUI
import PhotosUI
import UIKit

struct SelectPicture: UIViewControllerRepresentable{
    // 親View(GetSentenceView)から共有されたTextRecognitionインスタンス変数
    @ObservedObject var imageProcessor: TextRecognition

    func makeUIViewController(context: Context) -> PHPickerViewController {
        // PHPickerViewControllerの動作や設定する為のインスタンスを生成
        var config = PHPickerConfiguration()
        
        // 画像のみを選択可能にする
        config.filter = .images
        
        // メディアの選択最大数を1つに設定
        config.selectionLimit = 1
        
        // 写真や動画を選択する為の標準Viewコントローラーの生成
        let picker = PHPickerViewController(configuration: config)
        
        // UIKitの操作結果をSwiftUIで扱えるようにする
        picker.delegate = context.coordinator
        return picker
    }

    func updateUIViewController(_ uiViewController: PHPickerViewController, context: Context) {}

    func makeCoordinator() -> Coordinator {
        Coordinator(imageProcessor: imageProcessor)
    }

    // PHPickerViewControllerDelegateでメディア(写真や動画)を取得する為のクラス
    // デリゲートを扱うためにNSObjectを継承する必要あり
    class Coordinator: NSObject, PHPickerViewControllerDelegate {
        let imageProcessor: TextRecognition

        init(imageProcessor: TextRecognition) {
            self.imageProcessor = imageProcessor
        }

        // PHPickerViewControllerDelegateプロトコルからの実装
        // ユーザがメディアを選択したときに呼び出されるメソッド
        func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) {
            // ピッカーを閉じる
            picker.dismiss(animated: true)

            // 選択した画像を取得
            // 画像選択画面で何も選択されたなった場合は処理終了
            guard let result = results.first else { return }
            
            // 選択したファイルが画像であるか判定
            if result.itemProvider.canLoadObject(ofClass: UIImage.self) {
                // 画像ファイルを取得してimage変数に格納
                result.itemProvider.loadObject(ofClass: UIImage.self) { (image, error) in
                    // 非同期処理でselectedImage変数に取得した画像ファイルを格納し、
                    // 文字識別メソッドを実行
                    DispatchQueue.main.async {
                        if let uiImage = image as? UIImage {
                            self.imageProcessor.selectedImage = uiImage
                            self.imageProcessor.recognizeText(from: uiImage)
                        }
                    }
                }
            }
        }
    }
}
TextRecognition.swift
import SwiftUI
import Vision

class TextRecognition : ObservableObject{
    // 選択された画像ファイルを格納する変数
    @Published var selectedImage: UIImage?
    
    // 識別された画像内文字列を格納する変数
    @Published var extractedText: String = ""

    // 画像解析メソッド
    func recognizeText(from image: UIImage) {
        // 画像処理向けのピクセルデータが取得できなかった場合、処理失敗とみなす
        guard let cgImage = image.cgImage else {
            extractedText = "画像の変換に失敗しました"
            return
        }

        // Visionのリクエストを作成
        // ここでどんな識別をしたいのかを定義する
        // resultRequest:テキスト認識結果格納変数
        // error:テキスト認識結果エラー格納変数
        let request = VNRecognizeTextRequest { (resultRequest, error) in
            // エラーが発生した場合、解析結果を「エラー:<具体的なエラーメッセージ>」とする
            // エラー発生後、すぐにreturnするのでスレッドを気にする必要がない為、「DispatchQueue.main.async」は未使用
            if let error = error {
                self.extractedText = "エラー: \(error.localizedDescription)"
                return
            }

            // 結果を取得
            // resultRequestからテキスト認識結果(VNRecognizedTextObservationの配列)を取得
            if let observations = resultRequest.results as? [VNRecognizedTextObservation] {
                // observations配列の各要素(compactMapでnilな要素は除く)に対して、
                let recognizedStrings = observations.compactMap { observation in
                    // 信頼度が1番高い認識結果をrecognizedStrings配列変数に格納(もし無ければnil)
                    observation.topCandidates(1).first?.string
                }
                
                // 非同期処理で画面表示用変数の更新
                DispatchQueue.main.async {
                    // 文字列認識結果配列から、各要素を取り出し改行(¥n)で結合し、画面表示用変数を更新
                    self.extractedText = recognizedStrings.joined(separator: "\n")
                }
            // もしVNRecognizedTextObservationの配列が取得できなかった場合
            } else {
                // 非同期処理で画面表示用変数の更新
                DispatchQueue.main.async {
                    self.extractedText = "テキストが検出されませんでした"
                }
            }
        }
        
        // 文字連式の認識レベルの設定
        // accurate:処理速度は遅いが、精度は高い
        // fast:処理速度は速いが、精度は低い
        request.recognitionLevel = .accurate

        // 識別対象を日本語に設定
        request.recognitionLanguages = ["ja"]

        // リクエストハンドラの作成
        // cgImage:文字識別対象画像
        // options:文字識別時のオプション指定(今回は指定無し)
        let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
        
        // バックグラウンドスレッドで処理を実施
        // qos:実行優先度を指定(userInitiated:ユーザの入力に応じて実行される処理向け)
        DispatchQueue.global(qos: .userInitiated).async {
            // エラーハンドリングをする為に、do-catch構文を使用
            do {
                // VNRecognizeTextRequestを実行
                try handler.perform([request])
            } catch {
                // もしエラーが発生した場合
                // 非同期処理で画面表示用変数の更新
                DispatchQueue.main.async {
                    self.extractedText = "リクエストの実行に失敗しました: \(error.localizedDescription)"
                }
            }
        }
    }
}
TextRecognitionView.swift
import SwiftUI

struct TextRecognitionView: View {
    // TextRecognitionクラスのインスタンスを作成し、GetImageViewからデータ引き継ぎ
    // TextRecognitionから文字認識結果を参照できるようにする
    @ObservedObject var imageProcessor: TextRecognition
    
    // TextStorageManagerクラスのインスタンスを作成
    @StateObject private var storageManager = TextStorageManager()
    
    // 保存処理実行フラグ
    @State private var isShowingSaveSentence = false
    
    // 写真選択表示画面遷移処理フラグ
    @State private var isShowingGetImageView = false
    
    // データ保存結果表示画面遷移処理フラグ
    @State private var isNextSaveView = false

    var body: some View {
        VStack {
            Text("抽出されたテキスト:")
                .font(.headline)
                .padding(.top)
            ScrollView {
                Text(imageProcessor.extractedText)
                    .padding()
                    .background(Color.gray.opacity(0.2))
                    .cornerRadius(8)
            }
            .frame(height: 200)
            
            Button(action: {
                // テキスト保存処理実行
                storageManager.saveText(self.imageProcessor.extractedText)
            }) {
                Text("認識結果を保存")
                    .padding()
                    .accentColor(Color.white)
                    .background(Color.blue)
            }
            // データ保存処理が完了したら次の画面へ遷移
            .navigationDestination(isPresented: $isNextSaveView) {
                ResultSaveSentenceView(storageManager: storageManager)
            }
            // データ保存処理を実施したか確認
            .onReceive(storageManager.$isSaving) { isCompleted in
                if isCompleted {
                    // データ保存結果表示画面遷移処理フラグをON
                    isNextSaveView = true
                }
            }

            Button(action: {
                // 写真選択表示画面遷移処理フラグをON
                isShowingGetImageView = true
            }) {
                Text("再度写真選択")
                    .padding()
                    .accentColor(Color.white)
                    .background(Color.blue)
            }
            // 写真選択画面へ遷移
            .navigationDestination(isPresented: $isShowingGetImageView) {
                GetImageView()
            }
        }
    }
}
TextStorageManager.swift
import SwiftUI
import RealmSwift

class TextStorageManager: ObservableObject {
    // Realmのインスタンスを作成
    private let realm = try! Realm()
    
    // データ保存処理結果を格納する変数
    @Published var saveResult: String = ""
    
    // データ保存処理実施フラグ
    @Published var isSaving: Bool = false
    
    // テキストを保存
    // saveTextメソッドを呼び出す際に、引数名なしで呼び出すようにする(「_」)
    func saveText(_ text: String) {
        // RecognizedTextクラスのインスタンスを生成
        let recognizedText = RecognizedText(text: text)

        // データを書き込む
        do {
            try realm.write {
                realm.add(recognizedText)
            }
            DispatchQueue.main.async {
                self.saveResult = "データの保存が成功しました"
            }
        } catch {
            DispatchQueue.main.async {
                self.saveResult = "データの保存に失敗しました: \(error.localizedDescription)"
            }
        }
        // データ保存処理実施フラグをON
        isSaving = true
    }

    // 保存されているテキストを取得
    func fetchTexts() -> Results<RecognizedText> {
        // realm.objects(RecognizedText.self):Realmに保存されているRecognizedTextのデータを取得
        // .sorted(byKeyPath: "date", ascending: false):取得したデータをdataプロパティで降順(ascending:false)にソート
        return realm.objects(RecognizedText.self).sorted(byKeyPath: "date", ascending: false)
    }
}
ResultSaveSentenceView.swift
import SwiftUI

struct ResultSaveSentenceView: View {
    // TextStorageManagerクラスのインスタンスを作成し、TextRecognitionViewからデータ引き継ぎ
    @ObservedObject var storageManager = TextStorageManager()
    
    // START画面遷移処理フラグ
    @State var isShowingStartView: Bool = false
    
    var body: some View {
        VStack{
            // データ保存処理結果の表示
            Text(storageManager.saveResult)
                .font(.headline)
                .padding(.top)
            
            Button(action: {
                // START画面遷移処理フラグをON
                isShowingStartView = true
            }) {
                Text("スタート画面へ戻る")
                    .padding()
                    .accentColor(Color.white)
                    .background(Color.blue)
            }
            // Backボタンを非表示
            .navigationBarBackButtonHidden(true)
            // 写真選択画面へ遷移
            .navigationDestination(isPresented: $isShowingStartView) {
                StartView()
                    // Backボタンを非表示にしながらSTART画面へ遷移
                    .navigationBarBackButtonHidden(true)
            }
        }
    }
}
RecognizedText.swift
import RealmSwift
import Foundation

class RecognizedText: Object {
    // データを一意とするための識別子
    // @Persisted を付けることでRealmデータベース用のプロパティとなる
    @Persisted(primaryKey: true) var _id: ObjectId

    // id を _id に紐付け
    // _id へそのままアクセスすることを防いでいる(_idの値の変更を防ぐ)
    var id: ObjectId { _id }
    
    // 識別結果文字列変数
    @Persisted var text: String
    
    // データ保存処理日変数
    @Persisted var date: Date
    
    // インスタンス生成時のinit処理
    convenience init(text: String) {
        // このタイミングでObjectプロトコルのinit()が実行され、_idにObjectIdが自動生成される
        self.init()
        
        self.text = text
        self.date = Date()
    }
}
BrowseSentenceView.swift
import SwiftUI

struct BrowseSentenceView: View {
    // TextStorageManagerクラスのインスタンスを作成
    @StateObject private var storageManager = TextStorageManager()
    
    var body: some View {
        NavigationStack {
            // ローカルに保存されているテキスト識別結果の表示
            List {
                // ForEach(..., id: \.id):取得したデータをidをキーにしてリスト化
                // storageManager.fetchTexts():Realmからデータ(Result<RecognizedText>)を取得
                ForEach(storageManager.fetchTexts(), id: \.id) { textItem in
                    VStack(alignment: .leading) {
                        Text(textItem.text)
                            .font(.headline)
                        Text(formatDate(textItem.date))
                            .font(.subheadline)
                            .foregroundColor(.gray)
                    }
                }
            }
            .navigationTitle("保存されたテキスト")
        }
    }
    
    // 日付をフォーマットする関数
    private func formatDate(_ date: Date) -> String {
        // DateFormatterインスタンスを作成
        let formatter = DateFormatter()
        
        // 日付フォーマットを「Feb 15, 2025」のように設定
        formatter.dateStyle = .medium
        
        // 時刻のフォーマットを「10:30 AM」のように設定
        formatter.timeStyle = .short
        
        // date型からString型へ変換して返す
        return formatter.string(from: date)
    }
}

ポイント

  • ローカルデータベースとして利用した Realm(Realm-Swift) について、事前にxcodeへインストールする必要がある
  • Realm-Swiftをインストールした後にそのままビルドするとエラーになるので、対処が必要
    • エラー内容:Swift package target 'Realm' is linked as a static library by '<プロジェクト名>' and 'Realm', but cannot be built dynamically because there is a package product with the same name.
    • xcodeで上プロジェクト名をクリックし、画面左の「TARGETS」を開き、General→Frameworks, Libraries, and Embedded Content」を確認
    • 「Realm」と「RealmSwift」が存在し、かつ「Embed」が「Do Not Embed」となっているのが原因
    • 「Realm」を削除し、「RealmSwift」のEmbedを「Embed & Sign」に変更するとビルドが成功した
    • 参考:https://zenn.dev/kabeya/scraps/ad9744041c9f33
  • Realm-Swiftをローカルデータベースとして使用する際、データモデル(モデルクラス)を作成し、そのクラスのインスタンスを生成/利用をすることになる
    • 今回作成したデータモデル(RecognizedTextクラス)は、ざっくりと以下で構成されている
      • データを一意とするための識別子(id)
      • データそのもの(text, date)
      • イニシャライザ
    • イニシャライザにconvenienceがついているが、以下理由により採用した
      • 「Persisted」がついた変数にはデフォルトでなにかしらの値を設定する必要がある
      • convenienceをつけないinit()を使用する場合、以下のようにして個別に値を設定する必要があり可読性が下がる
      let newText = RecognizedText()
      newText.text = "Hello, Realm!"
      newText.date = Date()
      
      • Realmのオブジェクトでイニシャライザを利用して簡単にプロパティに値を設定するため
  • Realmデータベースに対して処理を行う際、トランザクションを張って、その中で処理を実装する必要がある
    • トランザクションは try realm.write { } の部分
    • realm.add:データ追加(Insert)
    • データ更新(Update)
    //Realmのデータベースから1件目を取得
    let textToUpdate = realm.objects(RecognizedText.self).first!
    try! realm.write {
        textToUpdate.text = "更新されたテキスト"
    }
    
    • realm.delete:データ削除(Delete)
    let textToDelete = realm.objects(RecognizedText.self).first!
    try! realm.write {
        realm.delete(textToDelete)
    }
    
    • オブジェクト間の関係性の変更(1対多の関係性など)
    let parent = ParentObject()
    let child = ChildObject()
    try! realm.write {
        parent.children.append(child)
    }
    
    • 条件付き処理
    let texts = realm.objects(RecognizedText.self)
    try! realm.write {
        for text in texts {
            if text.text.contains("重要") {
                print(text.text)
            }
        }
    }
    
    • エラーハンドリング
    do {
        try realm.write {
            let newText = RecognizedText(text: "エラーを発生させるテキスト")
            realm.add(newText)
            
            // 何かの条件でエラーを投げる
            if someConditionFails {
                throw SomeCustomError.someError
            }
        }
    } catch {
        print("トランザクション中にエラーが発生しました: \(error)")
    }
    

終わりに

初めてのSwiftによるアプリ開発だったので、分からないことだらけでした。。
この記事を書くことで、今後見返したときに思い出せるように、少なくとも書いてあることは理解できるように細かくまとめられたかなと思います。(箇条書きでまとめすぎて見にくいかも。。)

また今までは開発環境構築時に何か躓くと、その場で対処したっきりになり、すぐに忘れてしまっていました。
が、そんな躓きも記録に残せたので今後の参考できるかなと思います。

元々この記事を書く前は、食品の栄養成分表示からデータを記録/閲覧するアプリを開発したい!というところからでしたが、今のところは開発できそう?です!

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?