1. 程式人生 > >解讀Android之資料儲存方案

解讀Android之資料儲存方案

本文翻譯自android官方文件,結合自己測試,整理如下。

Android提供了一些永久儲存資料的方法,可以根據具體的需求決定使用哪種方式儲存,例如私有資料,外部程式是否可以訪問等等。有以下幾種方法儲存:

  • Shared Preferences
    使用鍵值對儲存私有資料型別
  • Internal Storage(或稱為檔案儲存)
    使用內部儲存儲存私有資料
  • External Storage
    使用外部儲存儲存公共資料
  • SQLite Databases
    使用私有資料庫儲存結構化資料
  • Network Connection
    儲存在網路伺服器中

當然Android中提供了一種使用content provider可以將私有資料暴露給外部程式使用。有興趣地可以參考我之前翻譯的文章:

下面分別介紹以上四種(Network Connection不介紹)。

使用Shared Preferences

SharedPreferences類提供了一個基本的框架,能夠使我們儲存和檢索私有鍵值對,可以儲存的型別有:boolean,float,int,long,String。這些資料將會永久儲存。

為了能過獲得SharedPreferences物件,我們可以使用以下兩種方法中的任何一種:

  • getSharedPreferences()
    如果需要使用多個通過名字識別的儲存檔案,使用該方法。該方法需要指定檔名。
  • getPreferences()
    如果在activity中只需要一個儲存檔案的話,使用該方法。由於該方法只能建立一個檔案,因此不需要提供檔名。

然後,可以通過下面的步驟完成寫資料:

  1. 呼叫edit()獲得SharedPreferences.Editor物件;
  2. 通過putXXX()方法新增資料;
  3. 完成新增資料時呼叫commit()

為了讀取資料,可以使用SharedPreferences中的getXXX()方法。

注意:Shared Preferences方式不是嚴格意義上的儲存使用者偏好(user preference),例如儲存使用者選擇的鈴聲。若想要實現這種功能的話可以繼承PreferenceActivity類,該類是Activity框架,但是能夠自動永久儲存使用者偏好(也是使用Shared Preferences)。當然對於其他的控制元件來說,Android也提供了相應的處理辦法。例如:CheckBoxPreference, EditTextPreference, ListPreference, MultiSelectListPreference, PreferenceCategory, PreferenceScreen, SwitchPreference

。這部分會在後續更新,請持續關注我的部落格。

使用內部儲存

我們可以直接將資料儲存在內部儲存上。預設情況下,儲存在記憶體上的資料是對程式私有的,外部程式無法獲取。該儲存方式又可稱為檔案儲存,是android中一種比較簡單的儲存方式,它不對儲存內容進行任何處理(怎麼讀的怎麼存),就是利用java中的檔案輸入輸出流來管理資料。

我們可以通過以下方法實現內部儲存:

  1. 呼叫Context類中的openFileOutput()方法,該方法返回FileOutputStream
  2. 呼叫write()寫資料;
  3. 呼叫close()關閉流。

例如:


String FILENAME = "hello_file";
String string = "hello world!";
// FILENAME可能是存在或不存在的檔名,若存在則替換現有的
FileOutputStream fos = openFileOutput(FILENAME, Context.MODE_PRIVATE);
fos.write(string.getBytes());
fos.close();

openFileOutput()接收兩個引數:檔名和操作模式。檔名不能包括路徑,這是因為所有的檔案都有預設的位置:/data/data//files/。操作模式有:MODE_PRIVATE(預設情況,程式私有,若檔案存在覆蓋原有),MODE_APPEND(若檔案存在,則在內容後面新增而不是替換,若不存在直接建立),MODE_WORLD_READABLE(API17後已棄用),MODE_WORLD_WRITEABLE(API17後已棄用)。

通過上面的例子可以看到和java輸入輸出流一樣。

通過下列方法可以讀取資料:

  1. 呼叫openFileInput()方法,該方法返回FileInputStream;
  2. 呼叫read()方法讀取資料;
  3. 呼叫close()方法關閉流。

openFileInput()方法只接受一個檔名引數,系統會自動在/data/data//files/目錄下查詢,之後呼叫java流進行讀取資料。

注意:若想在編譯時儲存靜態檔案,該檔案儲存在專案的res/raw/目錄下。使用Resources的例項方法openRawResource()獲取InputStream讀取資料,該方法引數為:R.raw.<filename>。但是我們不能向該檔案中寫內容。

儲存快取檔案

若我們想快取某些資料而不是永久儲存的話可以使用Context類中的getCacheDir()方法開啟一個檔案,該檔案代表了一個可以儲存臨時檔案的絕對路徑(即/data/data//cache)。當內部儲存空間不足時,可能會刪除這些快取,然而我們通常需要在程式中限制並清除這些快取,大小最好不要超過1MB。當用戶把我們的程式解除安裝時應該刪除這些快取。

在獲得以上目錄檔案後就可以根據java輸入輸出流對檔案進行讀寫。

其它方法

Context中還提供了一下方法方便我們處理檔案儲存:

  • getFilesDir()
    獲得內部檔案儲存的絕對路徑。
  • getDir()
    開啟或建立一個內部儲存空間中的目錄。
  • deleteFile()
    刪除一個檔案
  • fileList()
    返回檔案列表。

使用外部儲存

每一個android相容的裝置都支援共享的外部儲存,我們可以儲存檔案資料。這些裝置可以是可拆卸的(例如SD卡)或者是內部的。儲存在外部儲存上的資料外部程式是可以獲取的,並能夠通過USB傳到電腦上進行修改。

注意:如果使用者連線到計算機或刪除外部裝置上的媒體檔案,外部儲存可能會變得不可用,並沒有安全強制執行儲存到外部儲存的檔案。所有應用程式都可以讀取和寫入存放在外部儲存上的檔案,使用者也可以移除它們。

獲取許可

想要讀取或寫入外部裝置上的檔案,我們的程式必須獲得READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE系統許可。例如:


<manifest ...>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    ...
</manifest>

若想同時獲得讀寫許可的話, 只需要另一個許可即可(寫許可隱式包含了讀許可)。

檢查媒體檔案的可用性

在使用外部裝置時,我們應該首先呼叫getExternalStorageState()來檢查媒體檔案是否可用。例如下面的方法:


/* 檢查外部裝置是否可以讀寫 */
public boolean isExternalStorageWritable() {
    String state = Environment.getExternalStorageState();
    if (Environment.MEDIA_MOUNTED.equals(state)) {
        return true;
    }
    return false;
}

/* 檢查外部裝置是否至少可以讀取 */
public boolean isExternalStorageReadable() {
    String state = Environment.getExternalStorageState();
    if (Environment.MEDIA_MOUNTED.equals(state) ||
        Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
        return true;
    }
    return false;
}

getExternalStorageState()也可以返回其它狀態,例如是否可以共享,是否被移除等。

儲存可以和其他程式共享的檔案

一般情況,新檔案應該放在一個公共的地方以便其它程式可以訪問,並且方便拷貝。例如用一個共享的公共目錄,Music/,Pictures/,Ringtones/。

為了獲得一個合適的公共目錄檔案,可以呼叫getExternalStoragePublicDirectory(),目錄型別可以有:DIRECTORY_MUSICDIRECTORY_PICTURESDIRECTORY_RINGTONES等。按照目錄型別建立檔案並存放相應型別的內容以便系統方便尋找。例如儲存媒體型別的檔案在相應的目錄中時,系統媒體掃描器能夠對檔案進行合適的分類(for instance, ringtones appear in system settings as ringtones, not as music)。

例如下面一個方法用於建立一個名為album資料夾用於存放圖片, 該資料夾在公共的圖片目錄下:


public File getAlbumStorageDir(String albumName) {
    // Get the directory for the user's public pictures directory.
    File file = new File(Environment.getExternalStoragePublicDirectory(
            Environment.DIRECTORY_PICTURES), albumName);
    if (!file.mkdirs()) {
        Log.e(LOG_TAG, "Directory not created");
    }
    return file;
}

注意:為了迴避媒體掃描器的掃描,我們可以建立一個以.nomedia為名字的空檔案。該檔案能夠禁止掃描器讀取媒體檔案。但是若我們的檔案是程式私有的,應該在私有的目錄下儲存它們。

儲存私有檔案

若想儲存程式私有檔案,則需要私有儲存目錄儲存檔案,可以呼叫getExternalFilesDir()。該方法接收一個型別引數,能夠指定子目錄的型別(例如DIRECTORY_MOVIES)。若不需要指定媒體目錄,可以需要傳遞null,來接收私有目錄的根目錄。

從Android4.4之後,讀寫私有目錄下的檔案不需要許可READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE。我們可以宣告使用許可權的最大版本號,如下:


<manifest ...>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
                     android:maxSdkVersion="18" />
    ...
</manifest>

注意:當程式被解除安裝後,這些檔案目錄將都會刪除。系統媒體掃描器不能夠讀取這些目錄的檔案。因此我們不能使用這些目錄儲存屬於使用者的媒體,例如使用者下載的圖片等,這些檔案應該儲存在公共的目錄下,以防在解除安裝程式後刪除這些檔案。

有時候,一種裝置,該裝置分配一個記憶體作為外部儲存,也可以提供一個SD卡插槽。那麼該裝置執行在4.3及以下系統上時,getExternalFilesDir()只能獲得內部儲存檔案,我們的app不能讀寫到SD卡上。4.4開始以上兩個位置都可以獲取,通過getExternalFilesDirs()方法,該方法返回檔案陣列。若想在低版本中使用,則可以使用相容庫中的靜態方法ContextCompat.getExternalFilesDirs()。雖然仍返回一個檔案陣列,但通常只有一個元素。

注意儘管通過getExternalFilesDir()getExternalFilesDirs()獲得的目錄不能通過MediaStore content provider獲取,但是其他擁有READ_EXTERNAL_STORAGE許可的程式能夠獲取所有外部儲存上的檔案。若要做到嚴格限制的話,需要使用內部儲存。

儲存快取檔案

通過呼叫getExternalCacheDir()可以用於儲存快取檔案。當用戶解除安裝程式時,這些檔案自動刪除。通過呼叫ContextCompat.getExternalCacheDirs()可以將快取檔案儲存在第二個儲存裝置上。

注意:為了充分利用檔案空間並且提高程式效能,因此管理好快取檔案非常重要,並且在不需要它們的時候移除它們。

在獲得以上目錄檔案後就可以根據java輸入輸出流對檔案進行讀寫。

使用資料庫

android完全支援SQLite資料庫,在程式內的任何類都可以訪問我們建立的資料庫,其它外部程式則不能直接訪問。

SQLite是一款輕量級的關係型資料庫,它的運算速度非常快,佔用資源非常小,通常只需要幾百KB的記憶體就能夠滿足,因此特別適合移動裝置。

建立SQLite資料庫的一個好的方法是建立一個抽象類SQLiteOpenHelper的子類,該抽象類是一個幫助類,可以方便的對資料庫進行建立和升級。在SQLiteOpenHelper類中有兩個重要的抽象方法:onCreate()onUpgrade(),我們必須實現這兩個方法,前者用於建立資料庫,後者用於升級資料庫。並且這兩個方法無須我們呼叫,系統會在合適的地方呼叫(下面有講到)。

SQLiteOpenHelper類中還有兩個重要的方法:getReadableDatabase()getWritableDatabase(),兩者都可以開啟(若沒有則建立)現有的資料庫,並返回一個SQLiteDabase物件,然後使用該物件就可以對資料庫進行對數的操作。。兩者的不同點在於:前者在資料庫不可寫入(如空間已滿)時,返回的物件只能以只讀的方式開啟資料庫;而後者將會丟擲異常。資料庫檔案會存放在/data/data//databases/目錄下。

由於SQLiteOpenHelper類沒有無參構造器,因此在繼承SQLiteOpenHelper類時,必須要呼叫父類構造器,而通常來說,我們可以呼叫引數較少的一個構造器。

下面我們來看一個具體的示例:


/**
 * SQLiteOpenHelper練習
 * SQLiteOpenHelper是一個管理SQLite資料庫的幫助抽象類
 * Created by sywyg on 2015/5/20.
 */
public class MyDatabaseHelper extends SQLiteOpenHelper{

    private Context mContext;
    /**
     * SQL語句,
     * SQLite中支援的資料型別包括:null, integer整型,text文字型別,real浮點型別,
     * blob二進位制型別(應該是任意輸入的數值)
     * primary key 表示設定主鍵,autoincrement表示id自動增長
     */
    public static final String CREATE_BOOK = "create table book(" +
            "id integer primary key autoincrement," +
            "author text," +
            " price real," +
            " state blob)";
    public static final String CREATE_CATEGORY = "create table category(" +
            "id integer primary key autoincrement," +
            "name text," +
            " state blob)";
    /**
     * java語法:若父類沒有無參構造器的話,則子類必須呼叫父類構造器,否則在例項化子類的時候無法呼叫父類構造器。
     * 因此這個構造器(或另一個引數多的構造器)是必須的
     * @param context 當前訪問資料庫的元件
     * @param name 資料庫名稱
     * @param factory 自定義Cursor ,一般為null
     * @param version 資料庫版本號,可用於對資料庫升級操作。
     */
    public MyDatabaseHelper(Context context, String name, SQLiteDatabase.CursorFactory factory, int version){
        super(context,name,factory,version);
        mContext = context;

    }

    /**
     * 新建資料庫時會執行,在這裡一般處理建立表的邏輯
     * @param db
     */
    @Override
    public void onCreate(SQLiteDatabase db) {
        //執行SQL語句,建立兩個表,可以封裝在一個方法中,方便在onUpgrade()中呼叫
        db.execSQL(CREATE_BOOK);
        db.execSQL(CREATE_CATEGORY);
        Toast.makeText(mContext,"Create succeeded",Toast.LENGTH_LONG).show();
    }

    /**
     * 用於對資料庫進行升級,當在例項化該類時傳入的version大於之前的值就會執行該方法
     * @param db
     * @param oldVersion
     * @param newVersion
     */
    @Override
    public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
        // 這種採用直接刪除的辦法會導致資料的丟失,其實是不合理的
        // 可以通過switch條件判斷:若產品升級的話,現有的資料表儲存
        // 而不是刪除表,且case塊中,不需要break語句。
        // 這樣能夠保證無論多少次更新,資料庫總是最新的。
        // 以上內容參考郭霖第一行。
        db.execSQL("drop table if exists book");
        db.execSQL("drop table if exists category");
        onCreate(db);
    }

    /**
     * 可以建立或開啟一個現有的資料庫(如果資料庫已經存在則開啟,若不存在則新建)
     * 返回一個可對資料庫讀寫操作的物件
     * 但資料庫不可寫入(如磁碟空間已滿)時,返回的物件只能以只讀的方式開啟資料庫
     * @return
     */
    @Override
    public SQLiteDatabase getReadableDatabase() {
        return super.getReadableDatabase();
    }

    /**
     * 可以建立或開啟一個現有的資料庫(如果資料庫已經存在則開啟,若不存在則新建)
     * 返回一個可對資料庫讀寫操作的物件
     * 但資料庫不可寫入時,將出現異常
     * @return
     */
    @Override
    public SQLiteDatabase getWritableDatabase() {
        return super.getWritableDatabase();
    }
}

為了讀寫資料,我們可以呼叫getWritableDatabase()getReadableDatabase()獲取SQLiteDabase物件,然後使用該類的方法就可以對資料庫進行對數的操作。我們可以使用query()方法查詢資料庫,若要執行更為複雜的查詢語句,則可以使用SQLiteQueryBuilder。

每一個SQLite查詢都會返回Cursor物件,使用該物件對查詢結果進行處理。

我們來看一下如何對資料庫進行處理:


/**
 * SQLite資料庫練習
 * @author sywyg
 * @since 2015.5.20
 */

public class MainActivity extends Activity {
    private MyDatabaseHelper helper;
    private SQLiteDatabase sqLiteDatabase;
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        // 建立資料庫幫助類
        helper = new MyDatabaseHelper(this,"BookStore.db",null,1);
    }
    /**
     * 按鈕點選事件
     * @param view
     */
    public void onButtonClick(View view) {
        switch (view.getId()) {
            /**
             * 獲得資料庫
             */
            case R.id.btn_create:
                sqLiteDatabase = helper.getReadableDatabase();
                break;
            /**
             * 插入資料(也可以直接執行SQL語句)。
             * SqLiteDatabase類中的例項方法insert()方法
             * insert()方法接受三個引數分別為:
             * 表名,不指定列的預設值null,ContentValues物件。
             * ContentValues類實現了Parcelable介面,提供一系列的put()方法。
             * 用於新增資料,put()方法引數為:列名,值。
             */
            case R.id.btn_add:
                sqLiteDatabase =  helper.getWritableDatabase();
                ContentValues values = new ContentValues();
                values.put("author","sywyg");
                values.put("price", 25);
                values.put("state", 0);
                sqLiteDatabase.insert("book", null, values);
                //清除values中的值
                values.clear();
                //插入第二條資料
                values.put("author","sywyg2");
                values.put("price", 15);
                //values.put("state",1);
                sqLiteDatabase.insert("book",null,values);
                Toast.makeText(this, "add succeeded", Toast.LENGTH_LONG).show();
                break;
            /**
             * 刪除資料(也可以直接執行SQL語句)
             * delete()方法引數分別為:表名,第二和第三個為約束條件
             */
            case R.id.btn_delete:
                sqLiteDatabase =  helper.getReadableDatabase();
                //問號表示佔位符,由第三個引數中的字串陣列指定相應的內容
                sqLiteDatabase.delete("book","author = ?",new String[]{"sywyg2"});
                Toast.makeText(this, "delete succeeded", Toast.LENGTH_LONG).show();
                break;
            /**
             * 更新資料(也可以直接執行SQL語句)
             * update()方法引數分別為:表名,ContentValues物件,第三和第四個為約束條件
             */
            case R.id.btn_update:
                sqLiteDatabase =  helper.getWritableDatabase();
                ContentValues values1 = new ContentValues();
                values1.put("author","wygsy");
                values1.put("price", 100);
                //更新author為sywyg且price為15的資料
                sqLiteDatabase.update("book", values1, "author = ? and price = ?", new String[]{"sywyg", "15"});
                Toast.makeText(this, "update succeeded", Toast.LENGTH_LONG).show();
                break;
            /**
             * 查詢資料(也可以直接執行SQL語句)
             * query()方法引數分別為:
             */
            case R.id.btn_select:
                sqLiteDatabase =  helper.getReadableDatabase();
                Cursor cursor = sqLiteDatabase.query("book", null, null, null, null, null, null);
                while (cursor.moveToNext()) {
                    int id = cursor.getInt(cursor.getColumnIndex("id"));
                    String author = cursor.getString(cursor.getColumnIndex("author"));
                    Toast.makeText(this, "id:" + id + ",author:" + author, Toast.LENGTH_LONG).show();
                }
                break;
        }
    }
}

程式碼中已經解釋的很清楚,不再多說。需要多說的是關於資料庫的增刪改查(CRUD)操作,我已在contentprovider中講的很清楚了,有興趣的可以去看看:解讀Android之ContentProvider(1)CRUD操作解讀Android之ContentProvider(2)建立自己的Provider
當然上面的CRUD也直接可以使用SQL語句處理,使用SQLiteDatabase物件的execSQL()

若要實現插入資料的唯一性可以使用insertWithOnConflict()同時需要在建立表時指定個不允許重複的欄位設為主鍵PrimaryKey或者唯一性索引UNIQUE。

Android沒有增加任何超出SQLite語句的限制。我們推薦使用一個自動增加的主鍵,但是這個不是必須的。對於content provider來說,這個主鍵(BaseColumns._ID)是必須的。

使用事務

這部分內容來自郭霖第一行。

SQLite資料庫是支援事務的,事務的特性是保證某一系列操作要麼都執行,要麼都不執行。那麼如何使用事務呢?

首先呼叫SQLiteDatabase物件的beginTransaction()開啟事務,然後在一個異常捕獲塊中去執行資料庫操作,當所有的操作完成後呼叫setTransactionSuccessful(),表示事務完成,最後在finally中呼叫endTransaction()關閉事務。

以上操作能夠保證一次事務的執行,若執行不到setTransactionSuccessful(),則所有資料庫操作都將無效。

資料庫除錯

Android SDK中的adb除錯工具中包括sqlite3命令,這些命令可以進行相關資料庫操作。這一部分將在Android Debug Bridge中介紹。