iOS で SQLite - FMDB の使い方 2017
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 に詳しい。特別なテーブル内の TEXT
を MATCH
で高速に検索可能となる。
この機能はオプションなので不要ならば使わなくてもよい。おなじみの 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/deinit
で open/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 インスタンスはスコープを抜けるまで add
や read
を繰り返しても同じデータベース接続が使いまわされる。
- (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
が処理結果となる。
CREATE
、INSERT (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 とほぼ共通。
CREATE
、INSERT (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
}
}
SELECT
は FMDatabase.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 let
で Optional
な変数を受ければ配下のスコープで 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
というメソッドを実装するとそれがテスト対象となる。テストを実行した時の処理順は以下。
setUp
testXXXX
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
を採用した。
この問題が気になるなら以下の記事も参照のこと。
- SwiftでのUnicode正規化問題 続編:HFS+との整合性 - Qiita
- Electronを使ってMac向けのアプリを開発する時のファイル名の扱いについて (所謂UTF-8-MAC問題) - Qiita
まとめ
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 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 2017-07-11T04:39:05Z
ご返信ありがとうございます.
画像はBLOB(NSData)として扱っています.すべてのデータを読み込む必要がないことはわかっていたのですが,どうしたら良いのか途方にくれていました.教えていただいたことを参考に画像をキャッシュできるよう実装してみます.
あとSELECT文で,ORDER BYを削除してその後でソートをかけてみると1000~2000件程度であれば,サクサク動くようになりました.しかし5000件近くになるとまたクラッシュするようですので,やはりcommentいただいたような方法が必要ということですね.勉強になりました,ありがとうございました.