Android には、効率的な SQL データベースである SQLite の組み込みサポートが 用意されています。データの増加に伴ってアプリの速度と予測可能性を維持するには、以下のベスト プラクティスに沿ってアプリのパフォーマンスを最適化してください。これらのベスト プラクティスを使用すると、再現とトラブルシューティングが難しいパフォーマンスの問題が発生する可能性も低くなります。
パフォーマンスを改善するには、次のパフォーマンス原則に従います。
読み取り対象の行数と列数を減らす: 必要なデータのみを取得するようにクエリを最適化します。データ取得量が過剰になるとパフォーマンスに影響する可能性があるため、データベースから読み取られるデータの量は最小限に抑えてください。
SQLite エンジンに処理を push する: SQL クエリ内で計算、フィルタリング、並べ替えを行います。SQLite のクエリエンジンを使用すると、パフォーマンスが大幅に向上する場合があります。
データベース スキーマを変更する: SQLite で効率的なクエリプランとデータ表現を作成できるようにデータベース スキーマを設計します。テーブルを適切にインデックス登録し、テーブル構造を最適化してパフォーマンスを改善します。
また、利用可能なトラブルシューティング ツールを使用して、最適化が必要な領域を特定できるように SQLite データベースのパフォーマンスを測定することもできます。
Jetpack Room ライブラリを使用することをおすすめします。
パフォーマンスを重視してデータベースを構成する
このセクションの手順に沿って、SQLite でパフォーマンスを最適化するためにデータベースを構成します。
write-ahead log 書き込みを有効にする
SQLite は、ログに追加することによってミューテーションを実装し、場合によってはデータベースに圧縮します。これは、write-ahead log 書き込み(WAL)と呼ばれます。
ATTACH DATABASE を使用していない限り、WAL を有効にします。
同期モードを緩和する
WAL を使用する場合、デフォルトでは、データが確実にディスクに届くようにすべての commit で fsync が発行されます。これにより、データの耐久性は向上しますが、commit は低速になります。
SQLite には、同期モードを制御するオプションがあります。WAL を有効にする場合は、同期モードを NORMAL に設定します。
// When opening the database
val paramsBuilder: SQLiteDatabase.OpenParams.Builder = SQLiteDatabase.OpenParams.Builder()
paramsBuilder.journalMode = SQLiteDatabase.SYNC_MODE_NORMAL
// Or: after having opened the database
db.execSQL("PRAGMA synchronous = NORMAL");
この設定では、データがディスクに保存される前に commit が返される場合があります。停電やカーネル パニックなどが原因で、デバイスがシャットダウンすると、commit されたデータが失われる可能性があります。ただし、ロギングが原因でデータベースが破損することはありません。
アプリのクラッシュのみが発生した状態では、データはディスクに届きます。ほとんどのアプリでは、この設定により、機材に費用を投じることなくパフォーマンスを改善できます。
効率的なテーブル スキーマを定義する
パフォーマンスを最適化し、データの消費を最小限に抑えるには、効率的なテーブル スキーマを定義します。SQLite を使用すると、効率的なクエリプランとデータが作成され、データをすばやく取得できます。このセクションでは、テーブル スキーマの作成に関するベスト プラクティスについて説明します。
INTEGER PRIMARY KEY について検討する
この例では、次のようにテーブルを定義してデータを入力します。
CREATE TABLE Customers(
id INTEGER,
name TEXT,
city TEXT
);
INSERT INTO Customers Values(456, 'John Lennon', 'Liverpool, England');
INSERT INTO Customers Values(123, 'Michael Jackson', 'Gary, IN');
INSERT INTO Customers Values(789, 'Dolly Parton', 'Sevier County, TN');
テーブル出力は次のとおりです。
| rowid | id | name | city |
|---|---|---|---|
| 1 | 456 | ジョン・レノン | リバプール(イギリス) |
| 2 | 123 | マイケル・ジャクソン | インディアナ州ゲーリー |
| 3 | 789 | ドリー パートン | テネシー州セビア郡 |
rowid 列は広告掲載オーダーを保持するインデックスです。rowid でフィルタリングされるクエリは高速 B ツリー検索として実装されますが、id でフィルタリングされるクエリは低速なテーブル スキャンです。
id によるルックアップを行う場合は、rowid 列の保存を回避することでストレージ内のデータ量が削減され、データベース全体で高速化を実現できます。
CREATE TABLE Customers(
id INTEGER PRIMARY KEY,
name TEXT,
city TEXT
);
この結果、テーブルは次のようになります。
| id | name | city |
|---|---|---|
| 123 | マイケル・ジャクソン | インディアナ州ゲーリー |
| 456 | ジョン・レノン | リバプール(イギリス) |
| 789 | ドリー パートン | テネシー州セビア郡 |
rowid 列を保存する必要はないため、id クエリは高速です。テーブルは広告掲載オーダーではなく id に基づいて並べ替えられる状態になっている点に留意してください。
インデックスによりクエリを高速化する
SQLite はインデックスを使用してクエリを高速化します。テーブルにインデックスがある場合に、列のフィルタリング(WHERE)、並べ替え(ORDER BY)、または集計(GROUP BY)を行うと、クエリが高速化されます。
前の例では、city でフィルタするには、テーブル全体をスキャンする必要があります。
SELECT id, name
WHERE city = 'London, England';
多くの都市に関するクエリがあるアプリの場合は、インデックスでそれらのクエリを高速化できます。
CREATE INDEX city_index ON Customers(city);
インデックスは、追加のテーブルとして実装され、インデックス列で並べ替えて rowid にマッピングされます。
| city | rowid |
|---|---|
| インディアナ州ゲーリー | 2 |
| リバプール(イギリス) | 1 |
| テネシー州セビア郡 | 3 |
city 列が元のテーブルとインデックスの両方に存在するようになったため、ストレージ費用が 2 倍になりました。インデックスを使用しているため、ストレージを追加すれば、クエリを高速化できるというメリットが得られます。ただし、クエリ パフォーマンスの改善につながらないストレージ費用の支払いが発生しないように、使用していないインデックスは保持しないでください。
複数列インデックスを作成する
クエリで複数の列を組み合わせる場合は、 複数列インデックス を作成してクエリを十分に高速化できます。外側の列でインデックスを使用して、内部検索をリニアスキャンとして行うこともできます。
たとえば、次のクエリがあるとします。
SELECT id, name
WHERE city = 'London, England'
ORDER BY city, name
クエリで指定されているのと同じ順序で、複数列インデックスを使用してクエリを高速化できます。
CREATE INDEX city_name_index ON Customers(city, name);
ただし、city にのみインデックスがある場合は、外部の順序付けは高速化されますが、内部の順序付けにはリニアスキャンが必要です。
プレフィックスによる問い合わせにも対応しています。たとえば、ON Customers (city, name) インデックスは city によってフィルタリング、並べ替え、グループ化を高速化することもできます。これは、複数列インデックスのインデックス テーブルは、指定されたインデックスによって指定された順序で並べ替えられるためです。
WITHOUT ROWID について検討する
デフォルトでは、SQLite はテーブルの rowid 列を作成します。ここで、rowid は暗黙的な INTEGER PRIMARY KEY AUTOINCREMENT です。すでに INTEGER PRIMARY KEY という列がある場合、この列は rowid のエイリアスになります。
INTEGER 以外の主キーまたは列の複合を持つテーブルの場合は、WITHOUT ROWID を検討してください。
サイズの小さなデータを BLOB として保存し、サイズの大きなデータをファイルとして保存する
連絡先の画像や写真などのサムネイルにサイズの大きなデータを関連付ける場合は、対象のデータを BLOB 列またはファイルに保存し、パスを列に保存します。
ファイルは通常 4 KB 単位で切り上げられます。非常にサイズの小さなファイルでは、丸め誤差が大きい場合は、BLOB としてデータベースに保存することで効率が改善されます。SQLite はファイル システム呼び出しを最小限に抑え、
基盤となるファイル システムよりも高速になる場合があります。
クエリのパフォーマンスを向上させる
応答時間を最小限に抑え、処理効率を最大化することで、SQLite のクエリ パフォーマンスを向上させる際のおすすめの方法を以下に示します。
必要な行のみを読み取る
フィルタを使用すると、期間、場所、名前などの特定の条件を指定して結果を絞り込めます。上限を使用して、表示される結果の数を制御できます。
db.rawQuery("""
SELECT name
FROM Customers
LIMIT 10;
""".trimIndent(),
null
).use { cursor ->
while (cursor.moveToNext()) {
// Process cursor data
}
}
必要な列のみを読み取る
不要な列を選択すると、クエリの処理速度が低下し、リソースが浪費される可能性があるため回避する必要があります。代わりに、使用されている列のみを選択してください。
次の例では、id、name、phone を選択します。
// This is not the most efficient way of doing this.
// See the following example for a better approach.
db.rawQuery(
"""
SELECT id, name, phone
FROM customers;
""".trimIndent(),
null
).use { cursor ->
while (cursor.moveToNext()) {
val name = cursor.getString(1)
// Further processing
}
}
必要であるのは name 列のみです。
db.rawQuery("""
SELECT name
FROM Customers;
""".trimIndent(),
null
).use { cursor ->
while (cursor.moveToNext()) {
val name = cursor.getString(0)
// Further processing
}
}
クエリをパラメータ化する
クエリ文字列には、次のように実行時にのみ認識されるパラメータが含まれている場合があります。
fun getNameById(id: Long): String?
db.rawQuery(
"SELECT name FROM customers WHERE id=$id", null
).use { cursor ->
return if (cursor.moveToFirst()) {
cursor.getString(0)
} else {
null
}
}
}
上記のコードでは、クエリごとに異なる文字列が構築されるため、ステートメント キャッシュのメリットはありません。各呼び出しでは、実行する前に SQLite でコンパイルする必要があります。代わりに、id 引数を
パラメータと
selectionArgsで値をバインドできます。
fun getNameById(id: Long): String? {
db.rawQuery(
"""
SELECT name
FROM customers
WHERE id=?
""".trimIndent(), arrayOf(id.toString())
).use { cursor ->
return if (cursor.moveToFirst()) {
cursor.getString(0)
} else {
null
}
}
}
これで、クエリを一度コンパイルしてキャッシュに保存できます。コンパイルされたクエリは、getNameById(long) の呼び出し間で再利用されます。
コードではなく SQL で反復処理を行う
個々の結果を返すために SQL クエリを反復処理するプログラム ループではなく、ターゲットとするすべての結果を返す単一のクエリを使用します。プログラム ループは、単一の SQL クエリよりも約 1,000 倍遅くなります。
一意の値に DISTINCT を使用する
DISTINCT キーワードを使用すると、処理する必要のあるデータ量が削減され、クエリのパフォーマンスを向上させることができます。たとえば、列から一意の値のみを返す場合は、DISTINCT を使用します。
db.rawQuery("""
SELECT DISTINCT name
FROM Customers;
""".trimIndent(),
null
).use { cursor ->
while (cursor.moveToNext()) {
// Only iterate over distinct names in Kotlin
// Process distinct name
}
}
可能な限り集計関数を使用する
行データのない集計結果には、集計関数を使用します。たとえば次のコードでは、一致する行が 1 つ以上あるかどうかを確認します。
// This is not the most efficient way of doing this.
// See the following example for a better approach.
db.rawQuery("""
SELECT id, name
FROM Customers
WHERE city = 'Paris';
""".trimIndent(),
null
).use { cursor ->
if (cursor.moveToFirst()) {
// At least one customer from Paris
// Handle found
} else {
// No customers from Paris
// Handle not found
}
最初の行のみを取得するには、EXISTS() を使用して、一致する行が存在しない場合は 0 を返し、1 つ以上の行が一致する場合は 1 を返すようにできます。
db.rawQuery("""
SELECT EXISTS (
SELECT null
FROM Customers
WHERE city = 'Paris';
);
""".trimIndent(),
null
).use { cursor ->
if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
// At least one customer from Paris
// Handle found
} else {
// No customers from Paris
// Handle not found
}
}
アプリコードで SQLite 集計関数を使用します 。
COUNT: 列に含まれる行数をカウントします。SUM: すべての数値を列に追加します。MINまたはMAX: 最小値または最大値を指定します。数値列、DATE型、テキスト型で機能します。AVG: 平均数値を見つけます。GROUP_CONCAT: 文字列を必要に応じて区切り文字を使用して連結します。
Cursor.getCount() ではなく COUNT() を使用する
次の例では、
Cursor.getCount()関数
がデータベースからすべての行を読み取り、すべての行値を返します。
// This is not the most efficient way of doing this.
// See the following example for a better approach.
db.rawQuery("""
SELECT id
FROM Customers;
""".trimIndent(),
null
).use { cursor ->
val count = cursor.getCount()
// Use count
}
COUNT() を使用すると、データベースはカウントのみを返します。
db.rawQuery("""
SELECT COUNT(*)
FROM Customers;
""".trimIndent(),
null
).use { cursor ->
cursor.moveToFirst()
val count = cursor.getInt(0)
// Use count
}
コードではなくクエリをネストする
SQL はコンポーザブルであり、サブクエリ、結合、外部キー制約をサポートしています。アプリコードを使用せずに、あるクエリの結果を別のクエリで使用できます。これにより、SQLite からデータをコピーすることが必要な場合が少なくなり、データベース エンジンでクエリを最適化できます。
次の例では、顧客が最も多い都市を調べるクエリを実行し、結果を別のクエリで使用して、その都市のすべての顧客を検索できます。
// This is not the most efficient way of doing this.
// See the following example for a better approach.
db.rawQuery("""
SELECT city
FROM Customers
GROUP BY city
ORDER BY COUNT(*) DESC
LIMIT 1;
""".trimIndent(),
null
).use { cursor ->
if (cursor.moveToFirst()) {
val topCity = cursor.getString(0)
db.rawQuery("""
SELECT name, city
FROM Customers
WHERE city = ?;
""".trimIndent(),
arrayOf(topCity)).use { innerCursor ->
while (innerCursor.moveToNext()) {
// Process inner cursor data
}
}
}
}
上に示した例の半分の時間で結果を取得するには、ネストされたステートメントで単一の SQL クエリを使用します。
db.rawQuery("""
SELECT name, city
FROM Customers
WHERE city IN (
SELECT city
FROM Customers
GROUP BY city
ORDER BY COUNT (*) DESC
LIMIT 1;
);
""".trimIndent(),
null
).use { cursor ->
if (cursor.moveToNext()) {
// Process cursor data
}
}
SQL で一意性を確認する
テーブル内で特定の列の値が一意でない限り、行を挿入する必要がない場合は、該当する一意性を列の制約として適用した方が効率的です。
次の例では、挿入される行を検証するために 1 つのクエリを実行し、実際に挿入するために別のクエリを実行します。
// This is not the most efficient way of doing this.
// See the following example for a better approach.
db.rawQuery(
"""
SELECT EXISTS (
SELECT null
FROM customers
WHERE username = ?
);
""".trimIndent(),
arrayOf(customer.username)
).use { cursor ->
if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
throw AddCustomerException(customer)
}
}
db.execSQL(
"INSERT INTO customers VALUES (?, ?, ?)",
arrayOf(
customer.id.toString(),
customer.name,
customer.username
)
)
Kotlin の一意の制約を確認する代わりに、テーブルを定義するときに SQL で確認できます。
CREATE TABLE Customers(
id INTEGER PRIMARY KEY,
name TEXT,
username TEXT UNIQUE
);
SQLite は以下に示すのと同じことを行います。
CREATE TABLE Customers(...);
CREATE UNIQUE INDEX CustomersUsername ON Customers(username);
これで、行を挿入して、SQLite で制約を確認できるようになりました。
try {
db.execSql(
"INSERT INTO Customers VALUES (?, ?, ?)",
arrayOf(customer.id.toString(), customer.name, customer.username)
)
} catch(e: SQLiteConstraintException) {
throw AddCustomerException(customer, e)
}
SQLite は、複数の列を持つ一意のインデックスをサポートしています。
CREATE TABLE table(...);
CREATE UNIQUE INDEX unique_table ON table(column1, column2, ...);
SQLite は制約を Kotlin コードよりも高速かつ、より少ないオーバーヘッドで検証します。アプリのコードではなく SQLite を使用することをおすすめします。
1 つのトランザクションで複数の挿入をバッチ処理する
1 つのトランザクションで複数のオペレーションが commit されるため、効率だけでなく正確性も向上します。データの整合性を向上させ、パフォーマンスを高速化するには、一括挿入を使用します。
db.beginTransaction()
try {
customers.forEach { customer ->
db.execSql(
"INSERT INTO Customers VALUES (?, ?, ?)",
arrayOf(customer.id.toString(), customer.name, "customerValue")
)
}
} finally {
db.endTransaction()
}
トラブルシューティング ツールを使用する
SQLite には、パフォーマンス測定に活用できる次のトラブルシューティング ツールが用意されています。
SQLite のインタラクティブなプロンプトを使用する
マシンで SQLite を実行して、クエリを実行して学習します。Android プラットフォームのバージョンによって、使用される SQLite のリビジョンが異なります。Android 搭載デバイスと同じエンジンを使用するには、adb shell を使用して、ターゲット デバイスで sqlite3 を実行します。
SQLite でクエリを計測するように指示できます。
sqlite> .timer on
sqlite> SELECT ...
Run Time: real ... user ... sys ...
EXPLAIN QUERY PLAN
SQLite に、クエリに応答する方法について指示するには、EXPLAIN QUERY PLAN を使用します。
sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SCAN Customers
前述の例では、パリのすべての顧客を見つけるために、インデックスを使用しないフルテーブル スキャンが必要です。これを線形複雑性といいます。SQLite はすべての行を読み取り、パリの顧客に一致する行のみを保持する必要があります。これを解決するには、インデックスを追加します。
sqlite> CREATE INDEX Idx1 ON Customers(city);
sqlite> EXPLAIN QUERY PLAN
SELECT id, name
FROM Customers
WHERE city = 'Paris';
QUERY PLAN
`--SEARCH test USING INDEX Idx1 (city=?
インタラクティブ シェルを使用している場合は、常にクエリプランについて説明するように SQLite に指示できます。
sqlite> .eqp on
詳細については、クエリ プランニングをご覧ください。
SQLite Analyzer
SQLite には、パフォーマンスのトラブルシューティングに使用できる追加情報をダンプする sqlite3_analyzer コマンドライン インターフェース(CLI)が用意されています。インストールするには、
SQLite のダウンロード ページにアクセスします。
adb pull を使用すると、分析用にデータベース ファイルを対象デバイスからワークステーションにダウンロードできます。
adb pull /data/data/<app_package_name>/databases/<db_name>.db
SQLite ブラウザ
SQLite ダウンロード ページで、GUI ツールである SQLite ブラウザをインストールすることもできます。
Android のロギング
Android は、SQLite クエリを計測し、ログに記録します。
# Enable query time logging
$ adb shell setprop log.tag.SQLiteTime VERBOSE
# Disable query time logging
$ adb shell setprop log.tag.SQLiteTime ERROR
Perfetto トレース
Perfetto を構成するときに、個々のクエリのトラックを含めるには、次のように 追加します。
data_sources {
config {
name: "linux.ftrace"
ftrace_config {
atrace_categories: "database"
}
}
}
dumpsys meminfo
adb shell dumpsys meminfo <package-name> は、アプリの
メモリ使用量に関連する統計情報(SQLite メモリの詳細など)を出力します。たとえば、これはデベロッパーのデバイスで adb shell dumpsys meminfo com.google.android.gms.persistent の出力から取得したものです。
DATABASES
pgsz dbsz Lookaside(b) cache hits cache misses cache size Dbname
PER CONNECTION STATS
4 52 45 8 41 6 /data/user/10/com.google.android.gms/databases/gaia-discovery
4 8 0 0 0 (attached) temp
4 52 56 5 23 6 /data/user/10/com.google.android.gms/databases/gaia-discovery (1)
4 252 95 233 124 12 /data/user_de/10/com.google.android.gms/databases/phenotype.db
4 8 0 0 0 (attached) temp
4 252 17 0 17 1 /data/user_de/10/com.google.android.gms/databases/phenotype.db (1)
4 9280 105 103169 69805 25 /data/user/10/com.google.android.gms/databases/phenotype.db
4 20 0 0 0 (attached) temp
4 9280 108 13877 6394 25 /data/user/10/com.google.android.gms/databases/phenotype.db (2)
4 8 0 0 0 (attached) temp
4 9280 105 12548 5519 25 /data/user/10/com.google.android.gms/databases/phenotype.db (3)
4 8 0 0 0 (attached) temp
4 9280 107 18328 7886 25 /data/user/10/com.google.android.gms/databases/phenotype.db (1)
4 8 0 0 0 (attached) temp
4 36 51 156 29 5 /data/user/10/com.google.android.gms/databases/mobstore_gc_db_v0
4 36 97 47 27 10 /data/user/10/com.google.android.gms/databases/context_feature_default.db
4 36 56 3 16 4 /data/user/10/com.google.android.gms/databases/context_feature_default.db (2)
4 300 40 2111 24 5 /data/user/10/com.google.android.gms/databases/gservices.db
4 300 39 3 17 4 /data/user/10/com.google.android.gms/databases/gservices.db (1)
4 20 17 0 14 1 /data/user/10/com.google.android.gms/databases/gms.notifications.db
4 20 33 1 15 2 /data/user/10/com.google.android.gms/databases/gms.notifications.db (1)
4 120 40 143 163 4 /data/user/10/com.google.android.gms/databases/android_pay
4 120 123 86 32 19 /data/user/10/com.google.android.gms/databases/android_pay (1)
4 28 33 4 17 3 /data/user/10/com.google.android.gms/databases/googlesettings.db
POOL STATS
cache hits cache misses cache size Dbname
13 68 81 /data/user/10/com.google.android.gms/databases/gaia-discovery
233 145 378 /data/user_de/10/com.google.android.gms/databases/phenotype.db
147921 89616 237537 /data/user/10/com.google.android.gms/databases/phenotype.db
156 30 186 /data/user/10/com.google.android.gms/databases/mobstore_gc_db_v0
50 57 107 /data/user/10/com.google.android.gms/databases/context_feature_default.db
2114 43 2157 /data/user/10/com.google.android.gms/databases/gservices.db
1 31 32 /data/user/10/com.google.android.gms/databases/gms.notifications.db
229 197 426 /data/user/10/com.google.android.gms/databases/android_pay
4 18 22 /data/user/10/com.google.android.gms/databases/googlesettings.db
DATABASES には次の情報が表示されます。
pgsz: 1 つのデータベース ページのサイズ(KB 単位)。dbsz: データベース全体のサイズ(ページ単位)。サイズを KB 単位で取得するには、pgszにdbszを掛けます。Lookaside(b): 接続ごとに SQLite ルックアサイド バッファに割り当てられたメモリ(バイト単位)。通常、これは非常に小さい値です。cache hits: SQLite はデータベース ページのキャッシュを保持します。これは、ページ キャッシュのヒット数です。cache misses: ページ キャッシュのミス数。cache size: キャッシュ内のページ数。サイズを KB 単位で取得するには、この数値にpgszを掛けます。Dbname: DB ファイルのパス。この例では、一部の DB の名前の末尾に(1)などの数字が付いています。これは、同じ基盤となるデータベースに複数の接続があることを示しています。統計情報は接続ごとに追跡されます。
POOL STATS には次の情報が表示されます。
cache hits: SQLite は準備済みステートメントをキャッシュに保存し、クエリの実行時に再利用しようとします。これにより、SQL ステートメントのコンパイルに必要な労力とメモリを節約できます。これは、ステートメント キャッシュのヒット数です。cache misses: ステートメント キャッシュのミス数。cache size: Android 17 以降では、キャッシュ内の準備済みステートメントの合計数が表示されます。以前のバージョンでは、この値は 他の 2 つの列に表示されるヒット数とミス数の合計に相当し、 キャッシュサイズを表すものではありません。
参考情報
コンテンツを表示する
あなたへのおすすめ
- 注: JavaScript がオフになっている場合はリンクテキストが表示されます
- 継続的インテグレーションでベンチマークを実行する
- フリーズしたフレーム
- Macrobenchmark を使用せずにベースライン プロファイルを作成して測定する