アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

iOS で SQLite - FMDB の使い方 2017

January 22, 2017開発FMDB, iOS, Objective-C, Swift

2011 年に書いた iOS で SQLite - FMDB の使い方という記事へ現在も結構なアクセスがある。

しかし当時は ARC すらなくサンプルとして古すぎる。なにしろ 5 年も前だし。また最近 iOS アプリ開発に戻ってきたこともあり、2017 年度の開発環境を学ぶ題材としてサンプルを再実装してみた。プロジェクトは GitHub に公開してある。

サンプル再実装における考察などは以下にまとめる。

開発方針

再実装にあたり開発方針を。

  • サンプル プロジェクトの機能と UI は元記事の内容を踏襲
  • Objective-C と Swift の両方を実装
  • FMDB は CocoaPods で管理
  • ユニット テストを実装する

なるべく最新の Objective-C と Storyboard を使用するのは当然として Swift 版も実装することにした。Swift は 2014 年に発表されてから破壊的な変更を繰り返してきたが Swift 3 で一段落ついたと認識している。

Swift 3の開発の振り返りとSwift 4の計画が記されたメールの紹介 - Qiita と冒頭で紹介されているメーリング リストを読むに Swift 4 は互換性が重視され Swift 1 〜 3 のような構文レベルの大変更は抑止されるだろう。というわけで今こそ Swift 入門のチャンスと判断した。

サンプルは Objective-C、Swift 共に同等の内容とする。馴染みある Objective-C から先に実装してそれを Swift へ移植。なるべく Swift 的に好ましい機能や記法を採用するがオブジェクトやメソッド定義の変更をともなうレベルの差分は控える。

FMDB のインストールは CocoaPods を採用。元記事では FMDB のソースをプロジェクトにコピーしていたが、この方法だとバージョン管理に難がある。パッケージ管理が利用可能ならそちらへ任せるほうがよい。

あわせて Xcode の提供するユニット テスト機能も試す。

FMDB とは?

iOS アプリ開発において SQLite を扱いやすくするためのライブラリ。Apple 的には Core Data 推しであり Xcode の GUI からテーブル編集可能などの優遇措置がある。

だが Core Data は基本的に SQLite を隠蔽する設計。そのため他のプラットフォームで培った SQL 知見を活かすには素で操作したくなる。特に iOS/Android 両対応のアプリを開発するなら DB 設計と SQL 文を共用したい場面もあるだろう。

しかし SQLite は C 言語で実装されているため Objective-C や Swift から利用しようとすると API や接続状態の管理などが実に厄介。FMDB はこの辺をわかりやすく面倒みてくれる。

要は JDBC とか Android でいう SQLiteDatabase、.NET の System.Data.SQLite みたいなものだ。クライアント言語によりそった簡易なデータベース接続と操作 API を提供してくれる。

CocoaPods と FMDB

CocoaPods による FMDB インストールの詳細は CocoaPods を試すにまとめたので、そちらを参照のこと。サンプル プロジェクトのリポジトリには Podfile を定義してあるので、これを clone してから

$ pod install

とすれば FMDB をインストールできる。その後に *.xcworkspace ファイルを Xcode で開けば FMDB への参照がプロジェクトに設定された状態となっている。Xcode で iOS アプリを開発する場合、現在は以下のパッケージ管理を利用可能。

これらから CocoaPods を選んだ理由について。CocoaPods は Objective-C と Swift に両対応している。

Carthage は CocoaPods よりも先に Swift 対応したことで注目を集めたシステムで機能は簡素。CocoaPods の不満点を解消するために生まれたらしい。

Swift Package Manager は Swift 用である。Swift は OSS 化されているため iOS アプリ以外の開発でも採用される可能性がある。そのため iOS や macOS とは独立したパッケージ管理となっているようだ。

これらのうち今回は Objective-C から利用可能で知見も多い CocoaPods を選んだ。CocoaPods は 2016/5 に v1.0 がリリースされ、Podfile まわりで互換問題もあったようだが現在は落ち着いている。つまり当面は安定するだろう。これも採用理由である。

CocoaPods でインストール可能な FMDB には複数の仕向けがある。ccgus/fmdbe の CocoaPods 欄から引用。

pod 'FMDB'
# pod 'FMDB/FTS'   # FMDB with FTS
# pod 'FMDB/standalone'   # FMDB with latest SQLite amalgamation source
# pod 'FMDB/standalone/FTS'   # FMDB with latest SQLite amalgamation source and FTS
# pod 'FMDB/SQLCipher'   # FMDB with SQLCipher

今回は FMDB/FTS を採用。FTS というのは全文検索モジュールのこと。SQLite FTS3 and FTS4 Extensions に詳しい。特別なテーブル内の TEXTMATCH で高速に検索可能となる。

この機能はオプションなので不要ならば使わなくてもよい。おなじみの SQLite へ便利機能が追加された程度であり、普通に使う分には FTS を意識することはないだろう。

DAO と RAII

FMDB はデータベース接続と SQL 文を実行する API を提供するのだが、その知識をカプセル化するため DAO (Data Access Object) パターンを採用。FMDB API を利用する DAO クラスと DAO インスタンスの生成を担当する Factory クラスを用意する。

FMDB におけるデータベースは FMDatabase - databaseWithPath などのメソッドから得られた FMDatabase インスタンスとなる。データベース接続する場合はこのインスタンスに対して open、接続を閉じるなら close メソッドを呼ぶ。

NSString   *path = @"データベース ファイルのパス";
FMDatabase *db = [FMDatabase databaseWithPath:path];
if ([db open]) {

  // ...データベース操作

  [db close];
}

データベース接続を安全に管理するため open/close を対応させる必要あり。簡単なのは対応をメソッド内でカプセル化することだ。なにかデータベースを操作したくなったらメソッドを追加、その中で open/close を完結させる。

しかしこの方法だと複数の操作を連続実行したい場合、その単位でカプセル化するか毎回 open/close することを覚悟してメソッドを複数実行することになる。元記事のサンプルではそうしていた。

今回はこれを避けるため DAO クラスのインスタンス生成と破棄を open/close へ対応させる。Objective-C は init/dealloc、Swift なら init/deinitopen/close を処理。いわゆる RAII (Resource Acquisition Is Initialization) 的な管理とした。

Objective-C と Swift はインスタンス生成と破棄を明確にハンドリングできるため RAII と相性もよい。ARC (Automatic Reference Counting) を利用していても参照カウンター管理が自動化されるだけ。インスタンス生成したスコープで参照が完結するなら、そこを抜けたときにカウンターがゼロとなり「インスタンス破棄 = データベース接続を閉じる」ことになる。

例えば以下のように DAO クラスを定義して

- (void)init:(FMDatabase *)db {
    if (!(db)) { return nil; }

    self = [super init];
    if (self) {
        self.db = db;
    }

    return self;
}

- (void)dealloc {
    [self.db close];
}

- (Book *)add:(NSString *)author title:(NSString *)title releaseDate:(NSDate *)releaseDate {
}

- (NSArray *)read {
}

DAO Factory クラスでは

- (BookDAO *)bookDAO {
    return [[BookDAO allo] init:[self connection]];
}

- (FMDatabase *)connection {
    FMDatabase* db = [FMDatabase databaseWithPath:self.dbFilePath];
    return ([db open] ? db : nil);
}

というようにデータベース接続を済ませた FMDatabase を渡してインスタンス生成。このメソッドから返された DAO インスタンスはスコープを抜けるまで addread を繰り返しても同じデータベース接続が使いまわされる。

- (void)sample {
    // データベースが open される
    BookDAO *dao = [self.daoFactory bookDAO];

    // DAO を使用したデータベース操作

    // DAO インスタンス破棄 & データベース接続を閉じる
}

イメージとしては上記のような感じ。

Objective-C から FMDB を利用する

FMDB 関連の API を参照する場合は

#import <FMDatabase.h>
#import <FMResultSet.h>

のように import。CocoaPods でインストールした場合はフレームワーク扱いとなる。面倒なのでフレームワーク名を省略しているが名前衝突を心配するなら <FMDB/FMDatabase.h> のように記述してもよい。

SQL 文の定義

SQL 文は NSString リテラルとして定義。FMDB の API 呼び出しで直に書くよりも定数にしておいたほうが管理しやすい。この辺の話は NSString 連結を利用して heredoc 風に定数を記述するにまとめた。定数の位置は DAO クラスの *.m ファイル冒頭にしておく。SQL 文が長く大量にある場合は別ファイルへ括りだしてもよいだろう。

CRUD

FMDB API の代表的なものとして FMDatabase はデータベース接続と SQL 文の実行を担当。FMResultSet が処理結果となる。

CREATEINSERT (UPDATE)DELETE には FMDatabase - executeUpdate メソッドを使用。これは第一引数に SQL 文となる NSString、それ以降は可変長引数になっていて SQL 文の Placeholder へ対応する。INSERT だとこんな感じ。

static NSString * const kSQLInsert = @""
"INSERT INTO "
  "books (author, title, release_date) "
"VALUES "
  "(?, ?, ?);";

- (Book *)add:(NSString *)author title:(NSString *)title releaseDate:(NSDate *)releaseDate {
    Book *book = nil;
    if ([self.db executeUpdate:kSQLInsert, author, title, releaseDate]) {
        NSInteger bookId = [self.db lastInsertRowId];
        book = [Book bookWithId:bookId author:author title:title releaseDate:releaseDate];
    }

    return book;
}

SELECT では FMDatabase - executeQuery メソッドを使用する。引数は executeUpdate と一緒。WHERE 句で条件指定する場合は第二引数以降も指定することになるだろう。以下は単純な全行取得の例。

static NSString * const kSQLSelect = @""
"SELECT "
  "id, author, title, release_date "
"FROM "
  "books;"
"ORDER BY "
  "author, title;";

- (NSArray *)read {
    NSMutableArray *books = [NSMutableArray arrayWithCapacity:0];
    FMResultSet    *results = [self.db executeQuery:kSQLSelect];

    while ([results next]) {
        [books addObject:[Book bookWithId:[results intForColumnIndex:0]
                                   author:[results stringForColumnIndex:1]
                                    title:[results stringForColumnIndex:2]
                              releaseDate:[results dateForColumnIndex:3]]];
    }

    return books;
}

戻り値は FMResultSet。これはカーソル型のオブジェクトで next メソッドを呼ぶことで取得された行カーソルが進む。next は成否を BOOL で返すため行の列挙は while で処理するとよい。

行カーソルが示す先のデータを取得するのも FMResultSet のメソッドになる。XXXXForColumnIndex 系が SELECT 文に指定された列のインデックス、XXXXForColumn 系は列名を指定して値を取得する。XXXX には返される値の型を示す。

インデックスと名前のどちらで取得するかはお好みで。インデックスは単純だが順番変更に弱い。名前の場合は管理が面倒だが名前さえ維持できれば順番へ依存せずに取得できる。

Swift から FMDB を利用する

Swift でフレームワーク内のクラスを利用する場合は名前空間を import するだけでよい。

import FMDB

非常に楽ちんだ。

SQL 文の定義

Swift でも Objective-C のようにクラスの外周に定数を宣言できるのだが SQL 文と DAO の関係は密なのでクラス単位の static 定数としておく。

class BookDAO: NSObject {
    private static let SQLCreate = "" +
    "CREATE TABLE IF NOT EXISTS books (" +
      "id INTEGER PRIMARY KEY AUTOINCREMENT, " +
      "author TEXT, " +
      "title TEXT, " +
      "release_date INTEGER" +
    ");"

    func create() {
        self.db.executeUpdate(BookDAO.SQLCreate, withArgumentsIn: nil)
    }
}

Swift の String は + で連結されるため、これを利用してインデントをつけている。

CRUD

FMDB API は Objective-C とほぼ共通。

CREATEINSERT (UPDATE)DELETE には FMDatabase.executeUpdate(sql:, withArgumentsIn:) メソッドなどを使用。第一引数に SQL 文となる String、第二引数の Array が SQL 文の Placeholder に対応づけられる。INSERT だとこんな感じ。

class BookDAO: NSObject {
    private static let SQLInsert = "" +
    "INSERT INTO " +
      "books (author, title, release_date) " +
    "VALUES " +
      "(?, ?, ?);"

    func add(author: String, title: String, releaseDate: Date) -> Book? {
        var book: Book? = nil
        if self.db.executeUpdate(BookDAO.SQLInsert, withArgumentsIn: [author, title, releaseDate]) {
            let bookId = db.lastInsertRowId()
            book = Book(bookId: Int(bookId), author: author, title: title, releaseDate: releaseDate)
        }

        return book
    }
}

SELECTFMDatabase.executeQuery(sql:, withArgumentsIn:) メソッドとなる。引数の仕様は executeUpdate と一緒。以下は単純な全取得の例。

class BookDAO: NSObject {
    private static let SQLSelect = "" +
    "SELECT " +
      "id, author, title, release_date " +
    "FROM " +
      "books;" +
    "ORDER BY " +
      "author, title;"

    func read() -> Array<Book> {
        var books = Array<Book>()
        if let results = self.db.executeQuery(BookDAO.SQLSelect, withArgumentsIn: nil) {
            while results.next() {
                let book = Book(bookId: results.long(forColumnIndex: 0),
                                author: results.string(forColumnIndex: 1),
                                title: results.string(forColumnIndex: 2),
                                releaseDate: results.date(forColumnIndex: 3))
                books.append(book)
            }
        }

        return books
    }
}

Objective-C に対する特徴的な違いとして Optional 型の扱いがある。

Objective-C は nil なインスタンスに対し [obj message] 形式でメソッドやプロパティを呼び出すと処理が空振りする。これを利用して nil チェックを避けるテクニックもあったりする (悪手という意見も散見されるが)。Swift なら if letOptional な変数を受ければ配下のスコープで nil 検証された安全なインスタンスを使用できる。

Objective-C の処理をそのまま Swift へ書き換えただけだと大量のエラーに見舞われて面食らうだろう。しかし慣れてくるとそれらが nil という曖昧な状態を避けるための設計ギプスとして有効であることを理解できる。unwrap まわりの面倒くささは確実に nil への抑止力となるはず。

こうした null/nil 安全については以下に詳しい。

Swift に触れ、私も null/nil 安全について以前より強く意識するようになった。

ユニット テスト

Xcode 標準のユニット テストを試す。

まず FMDB 関連は直に使用しない。DAO クラスのインスタンスは DAO Factory から得られるため、これらに関するものだけに依存する。

Xcode プロジェクトを作成する際に Include Unit Tests と UI Tests をチェックするとテスト用プロジェクトも追加される。前者がユニット テスト、後者は UI の動作をテストするものである。今回はユニット テストだけ使用した。

SQLite のデータベースはファイル単位として管理される。そのためテストを実行する都度、ファイルの存在をチェックして前回実行の結果に影響されぬよう注意が必要。もっと厳密にやるならテスト メソッド単位でファイルを消去するほうが安全だけど、そこまではしない。

Xcode のテストは Objective-C、Swift 共に XCTestCase を継承したテスト クラスを実装。テスト全体の開始に setUp、終了時は tearDown が呼び出されるため、データベースの生成と破棄や DAO Factory 生成などはこれらに絡めておこなう。

以下は Swift の例。

import XCTest

class BookDAOTests: XCTestCase {
    private let filePath = BookDAOTests.databaseFilePath()
    private var daoFactory: DAOFactory!

    override func setUp() {
        super.setUp()

        self.clean()

        self.daoFactory = DAOFactory(filePath: self.filePath)
        if let dao = self.daoFactory.bookDAO() {
            dao.create()
        }
    }

    override func tearDown() {
        self.clean()
        super.tearDown()
    }

    func clean() {
        let manager = FileManager.default
        if manager.fileExists(atPath: self.filePath) {
            do {
                try manager.removeItem(atPath: self.filePath)
            } catch {
                print("Error: faild to remove database file.")
            }
        }
    }

    private static func databaseFilePath() -> String {
        let paths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
        let dir   = paths[0] as NSString
        return dir.appendingPathComponent("test.db")
    }
}

テスト対象にしたいクラスは Xcode 右にあるファイルのプロパティから Target Membership で Tests 系プロジェクトもチェックに含める必要あり。Swift は *.swift、Objective-C なら *.m ファイルをチェックすればよい。

Objective-C では更に XCTestCase 派生クラス側のコードでテスト対象クラスのヘッダーを import しなければならない。ヘッダー管理の必要な言語って面倒だ。

これらの設定をせずにテストを実行しようとすると Tests プロジェクトのビルドでリンク エラーになる。Target Membership はよく忘れるので注意すること。

XCTestCase 派生クラスに testXXXX というメソッドを実装するとそれがテスト対象となる。テストを実行した時の処理順は以下。

  1. setUp
  2. testXXXX
  3. tearDown

実際に DAO クラスのデータ更新処理をテストしてみる。

class BookDAOTests: XCTestCase {
    func testUpdate() {
        if let dao = self.daoFactory.bookDAO() {
            let book = dao.add(author: "author", title: "title", releaseDate: Date())
            XCTAssertNotNil(book)

            // Before
            var books = dao.read()
            XCTAssertEqual(books[0].title, "title")

            // After
            let book2 = Book(bookId: (book?.bookId)!, author: (book?.author)!, title: "title2", releaseDate: (book?.releaseDate)!)
            XCTAssert(dao.update(book: book2))
            books = dao.read()
            XCTAssertEqual(books[0].title, "title2")

            XCTAssert(dao.remove(bookId: (book?.bookId)!))

        } else {
            XCTAssert(false)
        }
    }
}

テストにおける値の妥当性チェックは XCTAssert 系の関数で実施。値の性質ごとに関数が提供されているので適切なものを選ぼう。よく使うものとしては以下がある。

関数 機能
XCTAssert 一つの値に対して真であることを判定。偽ならば失敗する。
XCTAssertEqual 二つの値を比較。不一致ならば失敗し、それぞれの値の内容を表示する。Objective-C だと NSObject 系の比較には利用できないので注意する。Swift は問題なし。
XCTAssertEqualObjects 二つの値を比較。不一致なら失敗、それぞれの値の内容を表示。Objective-C で NSObject 系を比較する場合はこの関数を使用する。Swift は不要なので提供されていないようだ。
XCTAssertNil 一つの値に対して nil であることを判定。nil でなければ失敗する。
Not 系 各種 XCTAssert 関数名に Not のついたもの。例えば XCTAssertNotEqual など。元と判定が逆転する。

NSString を判定する場合は厳密さを意識する必要があるかもしれない。詳しくは以下を参照のこと。

Xcode 8.1 でもこの挙動はそのまま。例にある文字列をテストすると XCTAssertEqualObjects でも不一致となった。ここまで判定したいなら NSString - compare を使用する。

これが問題になるとしたら macOS の HFS+ みたいに Unicode 正規化で「が」を「か」と濁点にわけて管理しているものと、そうでない環境で得られたファイル名やパスの同一性などが考えられる。

データを扱う環境がひとつの系で完結しているとか文字エンコーディングと正規化の系が統一されていればよい。なので iOS アプリならあまり意識しなくてよさそう。そのため今回のサンプルにおけるテストでは XCTAssertEqualObjects を採用した。

この問題が気になるなら以下の記事も参照のこと。

まとめ

Objective-C と Swift について。

Objective-C は 2 年前まで使用していたのでさほど変化を感じない。元記事のサンプルと比較したら差分は大きいのだけど想定どおりである。

一方 Swift は新鮮だった。Swift 1 〜 3 までの変遷と混乱をチラ見して大変そうな印象しかなかったのだけど、実際にプログラミングしてみると nil 安全まわりが実によい。基本的に nil を抑止する方針であり、使わざるを得ないときは Optional 型として明示される点が気に入っている。これを経験したことによって null/nil 安全のない言語で書くときの設計も影響を受けるだろう。

ユニット テストについて。

他のプラットフォームにおける Power Assert 系のように XCTAssert 比較でも失敗時に値の詳細表示がほしい。用途ごと関数を使い分けるのは面倒。いちおう Swift には keygx/PAssert があるのだけど、こういうのは標準にしてもよいのではないか。Cocoa Touch を使用するから仕方ないことだがテストに iOS シミュレーターや実機を要するため実行が遅い。スクリプト言語の気軽なテストに慣れていると重く感じる。

FMDB について。

細かな改善はたくさん反映されているのだろうけど使用感は 5 年前と変わらず。これはよいことだ。普通に設計したらこうなるよね、という期待に答える直感的な API である。

以前よりサンプルの古さが気になっていたので、書き直せてスッキリした。

以下、余談。

この記事とサンプルは 2016 年末に書き終えていた。しかし本ブログで Swift コードを構文強調する手段として WP Code Highlight.js を使うため、ブログ全体の Markdown 移行をしているうちに 2017 年へずれ込む。

結局 WP Code Highlight.js は行間へ余計な <br> を自動挿入するのを防げなくて導入は見送った。仕方ないので SyntaxHighlighter Evolved: Swift Brush を導入して構文強調している。

Comments from WordPress

  • taimai taimai 2017-07-08T12:52:04Z

    こちらを参考にFMDBを実装させていただいております。おかげさまでわかりやすく実装できたのですが、データ件数が1000件を超えたあたりからデータ読み込み(SELECT 、executeQuery メソッド)に数分かかり場合によってはクラッシュします。データには画像も含まれています。[FMResultSet next]の部分で数分かかっているようです。解決策をご存知であればご教示ください。よろしくお願いします。

  • アカベコ アカベコ 2017-07-09T12:16:12Z

    画像を含んでいるとのことですが、これは DB に BLOB として定義しているということでしょうか?もしそうであれば DB 上はパスや id のように抽象化しておくことをオススメします。

    BLOB の読み書きは処理コストが高く、かつ SQL として値を判定できません。よって BLOB として格納するのではなく、画像を示すパスや id にしておいてオンデマンドに画像を読み取るほうがよいです。そのようにした上でも、画像はデータ的にかなり巨大ですので上限を設けたバッファリングする方がよいでしょう。

    iOS のようなモバイル端末であれば画面サイズの制約により、せいぜい数十の画像を表示できれば十分なはず。

    よって「画面に表示する最大数 + α」ぐらいをメモリーに読み込み、それを超えた場合は古いものから破棄してゆく設計が考えられます。このあたりは「iOS 画像 キャッシュ」でググるとライブラリーや実装についての解説記事がヒットするでしょう。

    また、パフォーマンスについて問題がある場合は計測と比較が必須です。問題と予想されるもの、今回であれば画像にアタリがついているわけですが、これを無効化してみて改善するか否かを計測する必要があります。計測なしに設計を変更するのは「雰囲気でデバッグ」であり問題を本質的に修正したのか、たまたま直ったのかが不明瞭になります。後者の場合、より深刻な問題を招く可能性もあります。

    DB で画像を無効化してもパフォーマンス改善されないのであれば別の要因があります。改善されるのであれば、前述のように画像を別管理にしてバッファリングとキャッシュを利用する設計に変更するのがよいでしょう。

  • taimai taimai 2017-07-11T04:39:05Z

    ご返信ありがとうございます.

    画像はBLOB(NSData)として扱っています.すべてのデータを読み込む必要がないことはわかっていたのですが,どうしたら良いのか途方にくれていました.教えていただいたことを参考に画像をキャッシュできるよう実装してみます.

    あとSELECT文で,ORDER BYを削除してその後でソートをかけてみると1000~2000件程度であれば,サクサク動くようになりました.しかし5000件近くになるとまたクラッシュするようですので,やはりcommentいただいたような方法が必要ということですね.勉強になりました,ありがとうございました.

Copyright © 2009 - 2023 akabeko.me All Rights Reserved.