Android SQL,你用對了嗎(一)——條件限定

提到SQLiteDatabase這個類,你們都不陌生。對數據庫進行增刪改查,免不了跟它打交道,其中:html

刪改查三個操做每每須要進行條件限定。限定條件經過參數String whereClause, String[] whereArgs來指定。java

whereClause的取值形如_id = ? AND condition1 >= ? OR condition2 != ?,其中的?用於參數綁定,按順序,填入whereArgs數組內。android

但說實話,使用這種方式,須要:sql

  1. 先將限定部分的SQL語句寫出來,將限定的參數替換爲?
  2. 記住次序,填入參數數組內。

這麼作一次還好,寫多了挺煩人的,若是後期修改的話,還須要仔細確保SQL語句書寫正確,再三確保修改不會弄錯參數順序。若是弄錯了?那隻能慢慢debug了。數據庫

爲了方便,有的同窗就直接放棄了whereClausewhereArgs這種搭配,直接傳入完整的SQL限定字符串做爲whereClause參數的值,放棄了參數化,在whereArgs參數傳入了null數組

這種用法同樣能達成咱們的需求,甚至用起來更加方便,爲何SDK無故端要搞得這麼複雜呢?答:一切都是爲了性能。緩存

源碼探究

下面咱們來看下源碼,探究下處理過程,扒一扒SDK在哪一步優化了性能。cookie

int delete(String table, String whereClause, String[] whereArgs)這個方法爲切入點,相關實如今SQLiteDatabase.java裏。app

/** * Convenience method for deleting rows in the database. * * @param table the table to delete from * @param whereClause the optional WHERE clause to apply when deleting. * Passing null will delete all rows. * @param whereArgs You may include ?s in the where clause, which * will be replaced by the values from whereArgs. The values * will be bound as Strings. * @return the number of rows affected if a whereClause is passed in, 0 * otherwise. To remove all rows and get a count pass "1" as the * whereClause. */
    public int delete(String table, String whereClause, String[] whereArgs) {
        acquireReference();
        try {
            // 組裝成完整的SQL語句,實例化SQLiteStatement
            SQLiteStatement statement =  new SQLiteStatement(this, "DELETE FROM " + table +
                    (!TextUtils.isEmpty(whereClause) ? " WHERE " + whereClause : ""), whereArgs);
            try {
                // 執行SQL語句
                return statement.executeUpdateDelete();
            } finally {
                statement.close();
            }
        } finally {
            releaseReference();
        }
    }複製代碼

源碼裏並沒作什麼神奇的事情,僅僅是組裝成完整的SQL語句,和參數數組一塊兒,實例化SQLiteStatement,而後執行這個語句。less

執行的過程實如今SQLiteStatement.java

/** * Execute this SQL statement, if the the number of rows affected by execution of this SQL * statement is of any importance to the caller - for example, UPDATE / DELETE SQL statements. * * @return the number of rows affected by this SQL statement execution. * @throws android.database.SQLException If the SQL string is invalid for * some reason */
    public int executeUpdateDelete() {
        acquireReference();
        try {
            // 獲取各個參數:sql語句、要綁定的參數等
            // 而後纔是真正的執行
            return getSession().executeForChangedRowCount(
                    getSql(), getBindArgs(), getConnectionFlags(), null);
        } catch (SQLiteDatabaseCorruptException ex) {
            onCorruption();
            throw ex;
        } finally {
            releaseReference();
        }
    }複製代碼

獲取參數,而後調用executeForChangedRowCount方法。這個方法在SQLiteSession.java

/** * Executes a statement that returns a count of the number of rows * that were changed. Use for UPDATE or DELETE SQL statements. * * @param sql The SQL statement to execute. * @param bindArgs The arguments to bind, or null if none. * @param connectionFlags The connection flags to use if a connection must be * acquired by this operation. Refer to {@link SQLiteConnectionPool}. * @param cancellationSignal A signal to cancel the operation in progress, or null if none. * @return The number of rows that were changed. * * @throws SQLiteException if an error occurs, such as a syntax error * or invalid number of bind arguments. * @throws OperationCanceledException if the operation was canceled. */
    public int executeForChangedRowCount(String sql, Object[] bindArgs, int connectionFlags, CancellationSignal cancellationSignal) {
        if (sql == null) {
            throw new IllegalArgumentException("sql must not be null.");
        }

        // 這裏雖然傳入了bindArgs,但並沒用到
        if (executeSpecial(sql, bindArgs, connectionFlags, cancellationSignal)) {
            return 0;
        }

        acquireConnection(sql, connectionFlags, cancellationSignal); // might throw
        try {
            // 真正用到sql和bindArgs的地方
            return mConnection.executeForChangedRowCount(sql, bindArgs,
                    cancellationSignal); // might throw
        } finally {
            releaseConnection(); // might throw
        }
    }複製代碼

Ok,繼續深刻,來到SQLiteConnection.java

/** * Executes a statement that returns a count of the number of rows * that were changed. Use for UPDATE or DELETE SQL statements. * * @param sql The SQL statement to execute. * @param bindArgs The arguments to bind, or null if none. * @param cancellationSignal A signal to cancel the operation in progress, or null if none. * @return The number of rows that were changed. * * @throws SQLiteException if an error occurs, such as a syntax error * or invalid number of bind arguments. * @throws OperationCanceledException if the operation was canceled. */
    public int executeForChangedRowCount(String sql, Object[] bindArgs, CancellationSignal cancellationSignal) {
        if (sql == null) {
            throw new IllegalArgumentException("sql must not be null.");
        }

        int changedRows = 0;
        final int cookie = mRecentOperations.beginOperation("executeForChangedRowCount",
                sql, bindArgs);
        try {
            // 獲取預先編譯過的SQL
            final PreparedStatement statement = acquirePreparedStatement(sql);
            try {
                throwIfStatementForbidden(statement);
                // 參數綁定
                bindArguments(statement, bindArgs);
                applyBlockGuardPolicy(statement);
                attachCancellationSignal(cancellationSignal);
                try {
                    // 交給SQLiteEngine執行
                    changedRows = nativeExecuteForChangedRowCount(
                            mConnectionPtr, statement.mStatementPtr);
                    return changedRows;
                } finally {
                    detachCancellationSignal(cancellationSignal);
                }
            } finally {
                releasePreparedStatement(statement);
            }
        } catch (RuntimeException ex) {
            mRecentOperations.failOperation(cookie, ex);
            throw ex;
        } finally {
            if (mRecentOperations.endOperationDeferLog(cookie)) {
                mRecentOperations.logOperation(cookie, "changedRows=" + changedRows);
            }
        }
    }複製代碼

首先,會經過acquirePreparedStatement去獲取PreparedStatement實例,源碼以下:

private PreparedStatement acquirePreparedStatement(String sql) {
        PreparedStatement statement = mPreparedStatementCache.get(sql);
        boolean skipCache = false;
        if (statement != null) {
            if (!statement.mInUse) {
                return statement;
            }
            // The statement is already in the cache but is in use (this statement appears
            // to be not only re-entrant but recursive!). So prepare a new copy of the
            // statement but do not cache it.
            skipCache = true;
        }

        final long statementPtr = nativePrepareStatement(mConnectionPtr, sql);
        try {
            final int numParameters = nativeGetParameterCount(mConnectionPtr, statementPtr);
            final int type = DatabaseUtils.getSqlStatementType(sql);
            final boolean readOnly = nativeIsReadOnly(mConnectionPtr, statementPtr);
            statement = obtainPreparedStatement(sql, statementPtr, numParameters, type, readOnly);
            if (!skipCache && isCacheable(type)) {
                mPreparedStatementCache.put(sql, statement);
                statement.mInCache = true;
            }
        } catch (RuntimeException ex) {
            // Finalize the statement if an exception occurred and we did not add
            // it to the cache. If it is already in the cache, then leave it there.
            if (statement == null || !statement.mInCache) {
                nativeFinalizeStatement(mConnectionPtr, statementPtr);
            }
            throw ex;
        }
        statement.mInUse = true;
        return statement;
    }複製代碼

能夠看到,這裏有個mPreparedStatementCache用於緩存以前生成過的PreparedStatement,若是以前有相同的SQL語句,則取出重用,避免重複編譯SQL。這個緩存本質上是一個LruCache<String, PreparedStatement>key爲sql語句。

也便是,若是咱們使用whereClausewhereArgs的方式操做數據庫的話,一樣的whereClause,不一樣的whereArgs取值,將能利用到這個緩存。但若是直接將限定語句拼接好,因爲參數取值是可變的,一旦發生改變,就變成不一樣的語句,天然沒法匹配上緩存,白白浪費了已編譯過的PreparedStatement實例。

順便貼下綁定參數的代碼:

private void bindArguments(PreparedStatement statement, Object[] bindArgs) {
        final int count = bindArgs != null ? bindArgs.length : 0;
        if (count != statement.mNumParameters) {
            throw new SQLiteBindOrColumnIndexOutOfRangeException(
                    "Expected " + statement.mNumParameters + " bind arguments but "
                    + count + " were provided.");
        }
        if (count == 0) {
            return;
        }

        final long statementPtr = statement.mStatementPtr;
        for (int i = 0; i < count; i++) {
            final Object arg = bindArgs[i];
            switch (DatabaseUtils.getTypeOfObject(arg)) {
                case Cursor.FIELD_TYPE_NULL:
                    nativeBindNull(mConnectionPtr, statementPtr, i + 1);
                    break;
                case Cursor.FIELD_TYPE_INTEGER:
                    nativeBindLong(mConnectionPtr, statementPtr, i + 1,
                            ((Number)arg).longValue());
                    break;
                case Cursor.FIELD_TYPE_FLOAT:
                    nativeBindDouble(mConnectionPtr, statementPtr, i + 1,
                            ((Number)arg).doubleValue());
                    break;
                case Cursor.FIELD_TYPE_BLOB:
                    nativeBindBlob(mConnectionPtr, statementPtr, i + 1, (byte[])arg);
                    break;
                case Cursor.FIELD_TYPE_STRING:
                default:
                    if (arg instanceof Boolean) {
                        // Provide compatibility with legacy applications which may pass
                        // Boolean values in bind args.
                        nativeBindLong(mConnectionPtr, statementPtr, i + 1,
                                ((Boolean)arg).booleanValue() ? 1 : 0);
                    } else {
                        nativeBindString(mConnectionPtr, statementPtr, i + 1, arg.toString());
                    }
                    break;
            }
        }
    }複製代碼

代碼很簡單,就很少解釋了。

數據驗證

囉囉嗦嗦貼了這麼多源碼,其實只是爲了證實,whereClause搭配whereArgs是頗有意義的。說是這麼說,但實際優化性能差多少呢?

經過demo驗證,執行一千次查詢的狀況:
簡單語句_ID = ?

無參數化 有參數化
112 65

加大複雜度_ID >= ?

無參數化 有參數化
150 71

再複雜點_ID >= ? AND COLUMN_CATEGORY LIKE ?

無參數化 有參數化
190 87

結論:

  • 隨着限定語句複雜度的上升,編譯一次SQL語句的耗時也隨之增長;
  • 使用參數化能有效提高性能,使用參數化語句能提升約一倍的性能(場景越複雜,效果越明顯)

因此...爲了性能考慮,寫代碼的時候別再用拼接字符串的方式直接生成限定語句了。

One more things...

但,最開始說起的那種不便的使用方式,難道就只能默默忍受了?答案顯然並非,經過簡單的抽象、封裝,可以實現以下的效果:

Statement statement =
        Statement.where(UPDATE_TIME).lessOrEqual(now)
        .and(EXPIRY_TIME).moreThan(now)
        .or(AGE).eq(23)
        .end();
statement.sql(); // 生成sql語句
statement.whereClause(); // 生成whereClause語句
statement.args(); // 對應的參數數組複製代碼

這是我嘗試造的一個輪子,用於經過語義化的方式,定義和生成whereClausewhereArgs。用起來就像是寫sql語句同樣天然,同時還能避免人工書寫sql語句致使的一些拼寫錯誤,生成的whereClause的參數順序也和whereArgs參數數組嚴格對應。

寫得比較挫,就不發出來賣弄了。哈哈。若有錯誤的地方,還請各路大神指正!

相關文章
相關標籤/搜索