أفضل الممارسات لتحسين أداء SQLite (طرق العرض)

المفاهيم وتنفيذ Jetpack Compose

يوفّر Android دعمًا مضمّنًا لـ SQLite، وهي قاعدة بيانات SQL فعّالة. اتّبِع أفضل الممارسات التالية لتحسين أداء تطبيقك وضمان بقائه سريعًا مع نمو بياناتك. باتّباع أفضل الممارسات هذه، يمكنك أيضًا تقليل احتمالية مواجهة مشاكل في الأداء يصعب تكرارها وتحديدها وحلّها.

لتحقيق أداء أسرع، اتّبِع مبادئ الأداء التالية:

  • قراءة صفوف وأعمدة أقل: يمكنك تحسين طلبات البحث لاسترداد البيانات اللازمة فقط. قلِّل كمية البيانات التي تتم قراءتها من قاعدة البيانات، لأنّ استرداد البيانات الزائدة يمكن أن يؤثر في الأداء.

  • دفع العمل إلى محرّك SQLite: يمكنك إجراء العمليات الحسابية والفلترة والترتيب ضمن طلبات البحث في SQL. يمكن أن يؤدي استخدام محرّك طلبات البحث في SQLite إلى تحسين الأداء بشكل كبير.

  • تعديل مخطّط قاعدة البيانات: يمكنك تصميم مخطّط قاعدة البيانات لمساعدة SQLite في إنشاء خطط طلبات بحث وتمثيلات بيانات فعّالة. فهرسة الجداول بشكل صحيح وتحسين هياكل الجداول لتحسين الأداء.

بالإضافة إلى ذلك، يمكنك استخدام أدوات تحديد المشاكل وحلّها المتاحة لقياس أداء قاعدة بيانات SQLite للمساعدة في تحديد الجوانب التي تتطلّب التحسين.

ننصحك باستخدام مكتبة Jetpack Room.

ضبط قاعدة البيانات لتحسين الأداء

اتّبِع الخطوات الواردة في هذا القسم لضبط قاعدة البيانات من أجل تحقيق الأداء الأمثل في SQLite.

تخفيف وضع المزامنة

يتضمّن SQLite خيارًا للتحكّم في الوضع المتزامن. في وضع FULL، يصدر كل تثبيت fsync للمساعدة في ضمان وصول البيانات إلى القرص. يؤدي ذلك إلى تحسين متانة البيانات ولكنه يبطئ عمليات الإرسال. باستخدام WAL، لا تتم مزامنة وضع NORMAL في كل عملية تثبيت، ومع ذلك تتم حماية قاعدة البيانات من التلف.

عند تفعيل ميزة WAL من خلال إطار العمل، يطبّق Android الوضع التزامني التلقائي لميزة WAL على الجهاز، وهو NORMAL في مشروع Android المفتوح المصدر (AOSP) لنظام التشغيل Android 9 والإصدارات الأحدث. يمكن للشركات المصنّعة للأجهزة تغيير هذا الإعداد التلقائي، وقواعد البيانات التي لا تستخدم WAL تكون تلقائيًا FULL. للتأكد من أن قاعدة البيانات الخاصة بك تستخدم NORMAL، قم بتعيينها عند فتح قاعدة البيانات:

Kotlin

// When opening the database
val params = SQLiteDatabase.OpenParams.Builder()
    .addOpenFlags(SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING)
    // SYNC_MODE_NORMAL is available in API level 33 and higher.
    // On API levels 28 to 32, pass "NORMAL" instead.
    .setSynchronousMode(SQLiteDatabase.SYNC_MODE_NORMAL)
    .build()
// With SQLiteOpenHelper, call setOpenParams(params) in your constructor.

// Or: after having opened the database
db.execSQL("PRAGMA synchronous = NORMAL")

Java

// When opening the database
SQLiteDatabase.OpenParams params = new SQLiteDatabase.OpenParams.Builder()
    .addOpenFlags(SQLiteDatabase.ENABLE_WRITE_AHEAD_LOGGING)
    // SYNC_MODE_NORMAL is available in API level 33 and higher.
    // On API levels 28 to 32, pass "NORMAL" instead.
    .setSynchronousMode(SQLiteDatabase.SYNC_MODE_NORMAL)
    .build();
// With SQLiteOpenHelper, call setOpenParams(params) in your constructor.

// Or: after having opened the database
db.execSQL("PRAGMA synchronous = NORMAL");

في هذا الإعداد، يمكن أن يعود الإجراء commit قبل تخزين البيانات في قرص. في حال إيقاف تشغيل الجهاز، مثلاً عند انقطاع التيار الكهربائي أو حدوث ذعر النواة، قد يتم فقدان البيانات التي تم الالتزام بها. ومع ذلك، بسبب التسجيل، لا تتلف قاعدة البيانات الخاصة بك.

إذا تعطّل تطبيقك فقط، ستظل بياناتك تصل إلى القرص. بالنسبة إلى معظم التطبيقات، يؤدي هذا الإعداد إلى تحسينات في الأداء بدون أي تكلفة مادية.

تحسين أداء طلبات البحث

اتبع أفضل الممارسات هذه لتحسين أداء الاستعلام في SQLite عن طريق تقليل أوقات الاستجابة وزيادة كفاءة المعالجة إلى أقصى حد.

قراءة الصفوف التي تحتاجها فقط

تتيح لك الفلاتر تضييق نطاق النتائج من خلال تحديد معايير معيّنة، مثل النطاق الزمني أو الموقع الجغرافي أو الاسم. تتيح لك الحدود التحكم في عدد النتائج التي تراها:

Kotlin

db.rawQuery("""
    SELECT name
    FROM Customers
    LIMIT 10;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        // Process cursor data
    }
}

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name
    FROM Customers
    LIMIT 10;
    """, null)) {
  while (cursor.moveToNext()) {
    // Process cursor data
  }
}

قراءة الأعمدة التي تحتاجها فقط

تجنب تحديد الأعمدة غير الضرورية، والتي يمكن أن تبطئ استعلاماتك وتهدر الموارد. بدلاً من ذلك، اختَر فقط الأعمدة المستخدَمة.

في المثال التالي، يمكنك اختيار id وname وphone:

Kotlin

// 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
    }
}

Java

// This is not the most efficient way of doing this.
// See the following example for a better approach.

try (Cursor cursor = db.rawQuery("""
    SELECT id, name, phone
    FROM customers;
    """, null)) {
  while (cursor.moveToNext()) {
    String name = cursor.getString(1);
    // Further processing
  }
}

ومع ذلك، ما عليك سوى استخدام عمود name:

Kotlin

db.rawQuery("""
    SELECT name
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        val name = cursor.getString(0)
        // Further processing
    }
}

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name
    FROM Customers;
    """, null)) {
  while (cursor.moveToNext()) {
    String name = cursor.getString(0);
    // Further processing
  }
}

تحديد معلَمات طلبات البحث

قد تتضمّن سلسلة طلب البحث مَعلمة لا تُعرف إلا في وقت التشغيل، مثل ما يلي:

Kotlin

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
        }
    }
}

Java

@Nullable
public String getNameById(long id) {
  try (Cursor cursor = db.rawQuery(
      "SELECT name FROM customers WHERE id=" + id, null)) {
    if (cursor.moveToFirst()) {
      return cursor.getString(0);
    } else {
      return null;
    }
  }
}

في الرمز السابق، ينشئ كل طلب بحث سلسلة مختلفة، وبالتالي لا يستفيد من ذاكرة التخزين المؤقت للعبارات. يتطلب كل استدعاء SQLite لتجميعه قبل أن يتمكن من تنفيذه. بدلاً من ذلك، يمكنك استبدال الوسيطة id بمَعلمة وربط القيمة بـ selectionArgs:

Kotlin

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
        }
    }
}

Java

@Nullable
public String getNameById(long id) {
  try (Cursor cursor = db.rawQuery("""
          SELECT name
          FROM customers
          WHERE id=?
      """, new String[] {String.valueOf(id)})) {
    if (cursor.moveToFirst()) {
      return cursor.getString(0);
    } else {
      return null;
    }
  }
}

الآن يمكن تجميع طلب البحث مرة واحدة وتخزينه مؤقتًا. تتم إعادة استخدام طلب البحث المجمَّع بين عمليات الاستدعاء المختلفة لـ getNameById(long).

استخدام DISTINCT للقيم الفريدة

يمكن أن يؤدي استخدام الكلمة الرئيسية DISTINCT إلى تحسين أداء طلبات البحث من خلال تقليل كمية البيانات التي يجب معالجتها. على سبيل المثال، إذا أردت عرض القيم الفريدة فقط من عمود، استخدِم DISTINCT:

Kotlin

db.rawQuery("""
    SELECT DISTINCT name
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    while (cursor.moveToNext()) {
        // Only iterate over distinct names in Kotlin
        // Process distinct name
    }
}

Java

try (Cursor cursor = db.rawQuery("""
    SELECT DISTINCT name
    FROM Customers;
    """, null)) {
  while (cursor.moveToNext()) {
    // Only iterate over distinct names in Java
    // Process distinct name
  }
}

استخدام دوال التجميع كلما أمكن ذلك

استخدِم دوال التجميع للحصول على نتائج مجمّعة بدون بيانات الصفوف. على سبيل المثال، يتحقّق الرمز التالي مما إذا كان هناك صف واحد مطابق على الأقل:

Kotlin

// 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
}

Java

// This is not the most efficient way of doing this.
// See the following example for a better approach.

try (Cursor cursor = db.rawQuery("""
    SELECT id, name
    FROM Customers
    WHERE city = 'Paris';
    """, null)) {
  if (cursor.moveToFirst()) {
    // At least one customer from Paris
    // Handle found
  } else {
    // No customers from Paris
    // Handle not found
  }
}

لجلب الصف الأول فقط، يمكنك استخدام EXISTS() لعرض 0 إذا لم يتوفّر صف مطابق و1 إذا تطابق صف واحد أو أكثر:

Kotlin

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
    }
}

Java

try (Cursor cursor = db.rawQuery("""
    SELECT EXISTS (
      SELECT null
      FROM Customers
      WHERE city = 'Paris'
    );
    """, null)) {
  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: تدمج السلاسل مع فاصل اختياري.

استخدام COUNT() بدلاً من Cursor.getCount()

في المثال التالي، تقرأ الدالة Cursor.getCount() جميع الصفوف من قاعدة البيانات وتعرض جميع قيم الصفوف:

Kotlin

// 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
}

Java

// This is not the most efficient way of doing this.
// See the following example for a better approach.

try (Cursor cursor = db.rawQuery("""
    SELECT id
    FROM Customers;
    """, null)) {
  int count = cursor.getCount();
  // Use count
}

ومع ذلك، باستخدام COUNT()، تُرجع قاعدة البيانات العدد فقط:

Kotlin

db.rawQuery("""
    SELECT COUNT(*)
    FROM Customers;
    """.trimIndent(),
    null
).use { cursor ->
    cursor.moveToFirst()
    val count = cursor.getInt(0)
    // Use count
}

Java

try (Cursor cursor = db.rawQuery("""
    SELECT COUNT(*)
    FROM Customers;
    """, null)) {
  cursor.moveToFirst();
  int count = cursor.getInt(0);
  // Use count
}

استخدام طلبات البحث المتداخلة بدلاً من الرموز البرمجية

تتسم لغة SQL بأنّها قابلة للتركيب وتتيح الاستعلامات الفرعية وعمليات الربط وقيود المفاتيح الخارجية. يمكنك استخدام نتيجة طلب بحث واحد في طلب بحث آخر بدون الحاجة إلى استخدام رمز التطبيق. يقلّل ذلك من الحاجة إلى نسخ البيانات من SQLite ويتيح لمحرّك قاعدة البيانات تحسين طلب البحث.

في المثال التالي، يمكنك تشغيل استعلام للعثور على المدينة التي بها أكبر عدد من العملاء، ثم استخدام النتيجة في استعلام آخر للعثور على جميع العملاء من تلك المدينة:

Kotlin

// 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
            }
        }
    }
}

Java

// This is not the most efficient way of doing this.
// See the following example for a better approach.

try (Cursor cursor = db.rawQuery("""
    SELECT city
    FROM Customers
    GROUP BY city
    ORDER BY COUNT(*) DESC
    LIMIT 1;
    """, null)) {
  if (cursor.moveToFirst()) {
    String topCity = cursor.getString(0);
    try (Cursor innerCursor = db.rawQuery("""
        SELECT name, city
        FROM Customers
        WHERE city = ?;
        """, new String[] {topCity})) {
        while (innerCursor.moveToNext()) {
          // Process inner cursor data
        }
    }
  }
}

للحصول على النتيجة في نصف وقت المثال السابق، استخدم استعلام SQL واحدًا مع عبارات متداخلة:

Kotlin

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
    }
}

Java

try (Cursor cursor = db.rawQuery("""
    SELECT name, city
    FROM Customers
    WHERE city IN (
      SELECT city
      FROM Customers
      GROUP BY city
      ORDER BY COUNT(*) DESC
      LIMIT 1
    );
    """, null)) {
  while(cursor.moveToNext()) {
    // Process cursor data
  }
}

التحقّق من التفرّد في SQL

إذا كان يجب عدم إدراج صف ما لم تكن قيمة عمود معينة فريدة في الجدول، فقد يكون من الأكثر فعالية فرض هذا التفرد كقيد عمود.

In the following example, one query is run to validate the row to be inserted and another to actually insert:

Kotlin

// 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
    )
)

Java

// This is not the most efficient way of doing this.
// See the following example for a better approach.

try (Cursor cursor = db.rawQuery("""
    SELECT EXISTS (
      SELECT null
      FROM customers
      WHERE username = ?
    );
    """, new String[] { customer.username })) {
  if (cursor.moveToFirst() && cursor.getInt(0) == 1) {
    throw new AddCustomerException(customer);
  }
}
db.execSQL(
    "INSERT INTO customers VALUES (?, ?, ?)",
    new String[] {
      String.valueOf(customer.id),
      customer.name,
      customer.username,
    });

بدلاً من التحقّق من قيد التفرد في Kotlin أو Java، يمكنك التحقّق منه في 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 بالتحقّق من القيد:

Kotlin

try {
    db.execSQL(
        "INSERT INTO Customers VALUES (?, ?, ?)",
        arrayOf(customer.id.toString(), customer.name, customer.username)
    )
} catch(e: SQLiteConstraintException) {
    throw AddCustomerException(customer, e)
}

Java

try {
  db.execSQL(
      "INSERT INTO Customers VALUES (?, ?, ?)",
      new String[] {
        String.valueOf(customer.id),
        customer.name,
        customer.username,
      });
} catch (SQLiteConstraintException e) {
  throw new AddCustomerException(customer, e);
}

يتيح SQLite استخدام الفهارس الفريدة مع أعمدة متعددة:

CREATE TABLE table(...);
CREATE UNIQUE INDEX unique_table ON table(column1, column2, ...);

يتحقّق SQLite من صحة القيود بشكل أسرع وبنفقات أقل من رمز Kotlin أو Java. من أفضل الممارسات استخدام SQLite بدلاً من رمز التطبيق.

تجميع عمليات إدراج متعددة في معاملة واحدة

تنفّذ المعاملة عمليات متعددة، ما يحسّن الكفاءة والدقة أيضًا. لتحسين اتساق البيانات وتسريع الأداء، يمكنك تجميع عمليات الإدراج:

Kotlin

db.beginTransaction()
try {
    customers.forEach { customer ->
        db.execSQL(
            "INSERT INTO Customers VALUES (?, ?, ?)",
            arrayOf(customer.id.toString(), customer.name, "customerValue")
        )
    }
    db.setTransactionSuccessful()
} finally {
    db.endTransaction()
}

Java

db.beginTransaction();
try {
  for (Customer customer : customers) {
    db.execSQL(
        "INSERT INTO Customers VALUES (?, ?, ?)",
        new String[] {
          String.valueOf(customer.id),
          customer.name,
          "customerValue"
        });
  }
  db.setTransactionSuccessful();
} finally {
  db.endTransaction();
}