这节课是 Android 开发(入门)课程 的第四部分《数据与数据库》的第二节课,导师依然是 Jessica Lin 和 Katherine Kuan。这节课在掌握 SQLite 数据库基本操作的前提下,在 Pets App 的最小可行性应用 (MVP, Minimum Viable Product) 中使用 SQLite 数据库,主要参考 这篇 Android Developers 文档,内容包括
- 使用 Contract 类确定数据库架构。
- 使用 SQLiteOpenHelper 类创建、连接、管理数据库。
- 插入数据时使用的 ContentValues 类。
- 读取数据时使用的 Cursor 对象。
关键词:Contract Class、SQLiteOpenHelper、SQLiteDatabase 、ContentValues、Cursor、Floating Action Button、Spinner
Contract Class
在 Android 应用中使用 SQLite 数据库,首先需要使用 Contract 类确定数据库架构 (schema)。所谓数据库架构,可以理解为第一节课中提到的表格结构,主要是每一列的属性及其对应的存储类和限制信息等。Android 提供了 Contract 类来保存数据库架构,即保存表格的名称、每一列的属性名等。这样做的好处是,将数据库架构及其相关的常量统一放在一个类内,容易访问和管理;同时在利用数据库架构的常量生成 SQL 指令时,杜绝了错别字的可能性。
例如在 Pets App 中,通过内部类 PetEntry 定义了表格的名称为字符串常量 pets
,列 _id
作为数据库表格的 ID,宠物名字的列为字符串常量 name
等。
In java/com.example.android.pets/data/PetContract.java
/**
* Inner class that defines constant values for the pets database table.
* Each entry in the table represents a single pet.
*/
public static final class PetEntry implements BaseColumns {
/** Name of database table for pets */
public final static String TABLE_NAME = "pets";
/**
* Unique ID number for the pet (only for use in the database table).
*
* Type: INTEGER
*/
public final static String _ID = BaseColumns._ID;
/**
* Name of the pet.
*
* Type: TEXT
*/
public final static String COLUMN_PET_NAME ="name";
/**
* Possible values for the gender of the pet.
*/
public static final int GENDER_UNKNOWN = 0;
public static final int GENDER_MALE = 1;
public static final int GENDER_FEMALE = 2;
}
注意 PetContract 文件放在应用包名 (com.example.android.pets) 下单独的 data 包内,与 Activity 文件区分开。这是因为 Android 是通过包 (package) 来组织代码的,一类包存放一类特定功能的代码,例如 data 包下就存放数据相关的代码。
-
在调用 PetContract 中 PetEntry 的常量时,需要通过
PetContract.PetEntry
指定,例如PetContract.PetEntry.GENDER_MALE
这是因为调用处导入的包名默认为外部类 PetContract
import com.example.android.pets.data.PetContract;
为了精简代码,可以将包名指向 PetContract 的内部类
import com.example.android.pets.data.PetContract.PetEntry;
这样在调用其常量时,就可以省略 PetContract
PetEntry.GENDER_MALE
这种方法适合在 Contract 类只有一个表格的情况下使用。
PetContract 类要定义为
final
使之不能被扩展 (extends),因为它只对外提供常量,不实现任何功能。对于用 INETGER 来存储固定选项的数据,其对应的规则常量也在此定义。例如上面的宠物性别信息,0 表示未知,1 表示雄性,2 表示雌性。
SQLiteOpenHelper
在 Contract 类确定数据库架构后,使用 Android 提供的 SQLiteOpenHelper 类来创建、连接、管理数据库。SQLiteOpenHelper 可以看成是应用与数据库之间的桥梁。
SQLiteOpenHelper 是一个抽象类,实现时需要 override onCreate
和 onUpgrade
method。例如在 Pets App 中,创建一个 PetDbHelper 类,扩展自 SQLiteOpenHelper。注意文件路径与 PetContract 的相同,表示 PetDbHelper 类属于数据功能的代码。
In java/com.example.android.pets/data/PetDbHelper.java
public class PetDbHelper extends SQLiteOpenHelper {
private static final String DATABASE_NAME = "shelter.db";
private static final int DATABASE_VERSION = 1;
public PetDbHelper(Context context) {
super(context, DATABASE_NAME, null, DATABASE_VERSION);
}
@Override
public void onCreate(SQLiteDatabase db) {
// Create a String that contains the SQL statement to create the pets table
String SQL_CREATE_PETS_TABLE = "CREATE TABLE " + PetEntry.TABLE_NAME + " ("
+ PetEntry._ID + " INTEGER PRIMARY KEY AUTOINCREMENT, "
+ PetEntry.COLUMN_PET_NAME + " TEXT NOT NULL, "
+ PetEntry.COLUMN_PET_BREED + " TEXT, "
+ PetEntry.COLUMN_PET_GENDER + " INTEGER NOT NULL, "
+ PetEntry.COLUMN_PET_WEIGHT + " INTEGER NOT NULL DEFAULT 0);";
// Execute the SQL statement
db.execSQL(SQL_CREATE_PETS_TABLE);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// The database is still at version 1, so there's nothing to do be done here.
}
}
- 首先定义两个常量,分别表示数据库的名称,不要忘记后缀
.db
;以及数据库版本,初始值默认为 1。 - 构造函数调用超级类,输入参数分别为
(1)Context,应用环境,执行构造函数时传入;
(2)数据库名称,传入在外部类定义的常量;
(3)CursorFactory,设为 null 以使用默认值;
(4)数据库版本,传入在外部类定义的常量。 - override
onCreate
method 添加创建数据库时的指令,其中调用了 SQLiteDatabase 的execSQL
method 执行上面生成的CREATE TABLE
SQL 指令(存为字符串),实现创建数据库的操作。值得注意的是,由于execSQL
无返回值,所以它不能用来执行如SELECT
等带有返回值的 SQL 指令。 - override
onUpgrade
method 添加数据库版本发生变化时的指令,例如 SQLite 数据库表格增加了一列,数据库版本变更,可以在onUpgrade
使用execSQL
执行DROP TABLE
SQL 指令,删除旧表格后再调用onCreate
创建新数据库。不过在这里,目前数据库版本保持不变,所以onUpgrade
暂时留空,这部分内容将在后续课程修改。
实现 SQLiteOpenHelper 这个抽象类后,就可以通过它来创建、连接、管理 SQLiteDatabase 数据库了。例如在 Pets App 中,首先创建 SQLiteOpenHelper 实例。
In CatalogActivity.java
PetDbHelper mDbHelper = new PetDbHelper(this);
调用 PetDbHelper 的构造函数,传入 this 即当前 Activity 的环境。获得 PetDbHelper 实例后,调用 getReadableDatabase()
创建或连接 SQLiteDatabase 数据库。
SQLiteDatabase db = mDbHelper.getReadableDatabase();
这条指令相当于 sqlite3 的 .open
指令,用于打开或创建一个数据库文件。具体来说,应用在调用 getReadableDatabase()
时,PetDbHelper 会查询当前是否已存在数据库,若无,PetDbHelper 会执行其 onCreate
method 创建数据库,返回一个 SQLiteDatabase 对象;若有则不执行 onCreate
method,直接创建一个 SQLiteDatabase 对象,与已有的数据库连接。因此,无论应用是否已存在数据库,调用 getReadableDatabase()
的结果都是获得一个连接数据库的 SQLiteDatabase 对象,通过它来操作数据库。
不过事实上,通过 getReadableDatabase()
获得的 SQLiteDatabase 对象,只能对数据库进行读取 (Read) 操作,如果想要 CRUD 的其余操作,需要调用 getWritableDatabase()
获取 SQLiteDatabase 对象。
ContentValues
在获得一个连接数据库的 SQLiteDatabase 对象后,就可以通过它对数据库进行 CRUD 操作,其中经常用到 ContentValues 构造数据。ContentValues 是一个具象类,用于存储大量的键/值对,对于数据库而言,键是对象的属性,值是对应的属性值。例如在 Pets App 中,创建一个 ContentValues 对象,并通过 put
method 添加键/值对。
String nameString = mNameEditText.getText().toString().trim();
String breedString = mBreedEditText.getText().toString().trim();
String weightString = mWeightEditText.getText().toString().trim();
int weight = Integer.parseInt(weightString);
ContentValues values = new ContentValues();
values.put(PetEntry.COLUMN_PET_NAME, nameString);
values.put(PetEntry.COLUMN_PET_BREED, breedString);
values.put(PetEntry.COLUMN_PET_GENDER, mGender);
values.put(PetEntry.COLUMN_PET_WEIGHT, weight);
long newRowId = db.insert(PetEntry.TABLE_NAME, null, values);
- 添加到 ContentValues 对象的键为 Contract 类中定义的架构列常量,对应的值为从 EditText 获取的用户输入值。
- 在
toString
后调用trim
去除字符串开头和结尾的多余空格。 - 调用
Integer.parseInt()
使字符串转换为整数。
构造好往数据库添加的数据后,调用 SQLiteDatabase 的 insert
method 向数据库添加数据。输入参数分别为
- table: 要添加数据的表格名称。
- nullColumnHack: 可选参数,可设为 null,仅在往数据库添加空行时用到。
- values: 要往数据库添加的数据,数据类型为 ContentValues 对象。
SQLiteDatabase 的 insert
method 返回值为新添加的行 ID,发生错误时返回 -1。
Tips:
1. 调用 finish()
method 可以关闭当前 Activity,使屏幕界面回到原先 Activity。
2. 根据 Activity 的生命周期,在 onStart
中添加的代码,可以在用户通过 Activity 切换回来时执行。
Cursor
与往数据库添加数据类似,SQLiteDatabase 也提供了读取数据的方法,例如通过 rawQuery
传入 SQL 指令读取数据,不过还有更规范的 query
method,可以避免直接操作 SQL 指令导致的语法错误。SQLiteDatabase 提供了很多不同的 query
method,其中较简单的为
Cursor query (String table,
String[] columns,
String selection,
String[] selectionArgs,
String groupBy,
String having,
String orderBy)
有七个输入参数,分别为
- table
想要读取的表格名称。 - columns
想要读取的列数据,传入 null 表示读取所有列,但是不建议这么做,尤其是数据库很庞大时会消耗大量系统资源。 - selection
读取数据的筛选条件,相当于 SQL 指令的WHERE
条件,传入 null 表示无筛选条件,返回所有数据行。 - selectionArgs
与 selection 配合使用,当 selection 包含 "?" 时,selectionArgs 数组的字符串会填入相应的位置,作为读取数据的筛选条件。这种模式属于防止 SQL 注入的安全措施。 - groupBy
读取的数据行的分组条件,相当于 SQL 指令的GROUP BY
条件,传入 null 表示数据行不分组。 - having
在读取的数据行通过 groupBy 分组的情况下,指定哪一组包含 Cursor 内,相当于 SQL 指令的HAVING
条件,传入 null 表示所有组包含 Cursor 内。另外,当 groupBy 为 null,having 也要传入 null。 - orderBy
读取的数据行的排序方法,相当于 SQL 指令的ORDER BY
条件,传入 null 表示数据行保持默认排序。
例如 这篇 Android Developers 文档 中的例子,它读取的数据是 ID、标题、副标题三列,标题为 "My Title",按照副标题降序排列的,不分组的。其中 selection 中的 "= ?" 可以是一个等号,也可以是两个等号。
SQLiteDatabase db = mDbHelper.getReadableDatabase();
// Define a projection that specifies which columns from the database
// you will actually use after this query.
String[] projection = {
FeedEntry._ID,
FeedEntry.COLUMN_NAME_TITLE,
FeedEntry.COLUMN_NAME_SUBTITLE
};
// Filter results WHERE "title" = 'My Title'
String selection = FeedEntry.COLUMN_NAME_TITLE + " = ?";
String[] selectionArgs = { "My Title" };
// How you want the results sorted in the resulting Cursor
String sortOrder = FeedEntry.COLUMN_NAME_SUBTITLE + " DESC";
Cursor cursor = db.query(
FeedEntry.TABLE_NAME, // The table to query
projection, // The columns to return
selection, // The columns for the WHERE clause
selectionArgs, // The values for the WHERE clause
null, // don't group the rows
null, // don't filter by row groups
sortOrder // The sort order
);
SQLiteDatabase 的所有 query
method 的返回值都是一个 Cursor 对象,它保存了读取到的数据库的多行内容。在读取数据时,Cursor 可以看成是应用与 SQLiteDatabase 之间的桥梁。
Cursor 的一个重要概念是当前行的位置。在 Cursor 没有数据时,它的默认位置为无效的 -1,所以与 Array 类似,Cursor 的首个可用位置为 0,随后逐步递增。Cursor 提供了 move method 用于移动当前行的位置,以获取指定行的数据,常用的有
Method | Description |
---|---|
moveToFirst () | 使 Cursor 当前行的位置移至首行 |
moveToLast () | 使 Cursor 当前行的位置移至末行 |
moveToNext () | 使 Cursor 当前行的位置下移一行 |
moveToPosition (int position) | 使 Cursor 当前行的位置移至指定行,-1 <= position <= count |
moveToPrevious () | 使 Cursor 当前行的位置返回到原来那一行 |
Cursor 的 move method 的返回值类型都是布尔类型,移动成功为 true,失败则 false。例如 Cursor 当前行为最后一行时调用 moveToNext
的返回值即 false。
Cursor 还提供很多 getter method 用于获取不同类型的数据,例如 getInt()
、getString
、getType
等,它们的输入参数都是 columnIndex 列索引,这是由 projection 决定的,通过 getColumnIndex
获得。
使用完 Cursor 后,一定要调用 close
method 关闭 Cursor,防止内存泄漏。
以 Pets App 为例
try {
displayView.setText("The pets table contains " + cursor.getCount() + " pets.\n\n");
displayView.append(PetEntry._ID + " - " +
PetEntry.COLUMN_PET_NAME + " - " +
PetEntry.COLUMN_PET_BREED + " - " +
PetEntry.COLUMN_PET_GENDER + " - " +
PetEntry.COLUMN_PET_WEIGHT + "\n");
int idColumnIndex = cursor.getColumnIndex(PetEntry._ID);
int nameColumnIndex = cursor.getColumnIndex(PetEntry.COLUMN_PET_NAME);
int breedColumnIndex = cursor.getColumnIndex(PetEntry.COLUMN_PET_BREED);
int genderColumnIndex = cursor.getColumnIndex(PetEntry.COLUMN_PET_GENDER);
int weightColumnIndex = cursor.getColumnIndex(PetEntry.COLUMN_PET_WEIGHT);
while (cursor.moveToNext()) {
int currentID = cursor.getInt(idColumnIndex);
String currentName = cursor.getString(nameColumnIndex);
String currentBreed = cursor.getString(breedColumnIndex);
int currentGender = cursor.getInt(genderColumnIndex);
int currentWeight = cursor.getInt(weightColumnIndex);
displayView.append(("\n" + currentID + " - " +
currentName + " - " +
currentBreed + " - " +
currentGender + " - " +
currentWeight));
}
} finally {
cursor.close();
}
- 使用
append
为 TextView 添加内容。 - 使用
getColumnIndex
获取每一列的索引。 - 将
moveToNext
放入 while 循环语句中达到遍历所有数据行的效果。这是因为正常情况下,moveToNext
的返回值为 true 即进入循环;直到 Cursor 在最后一行时调用moveToNext
的返回值为 false 即跳出循环。 - 通过不同的 getter method 获取不同类型的数据。
- 将上述代码放入 try/finally 区块内,即使应用崩溃,也能保证执行
close
method 关闭 Cursor。
如何提取应用的数据库文件
在 Android Studio 界面右上角搜索 Device File Explorer 打开模拟器的文件浏览器,打开目录 data > data > com.example.android.pets > databases 即可查看应用内的数据库文件。右键选择 "Save As..." 将数据库文件提取到电脑中,即可通过终端访问。
Note:
1. 此方法仅适用于模拟器或具有最高权限 (rooted) 的物理设备。
2. 清除应用的缓存 (cache) 和数据 (data) 会删除应用的数据库文件。
Floating Action Button
Pets App 使用了 FloatingActionButton 连接两个 Activity,它是一个悬浮在 UI 之上的圆形按钮,有独特的显示和交互效果。
In activity_catalog.xml
<android.support.design.widget.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentBottom="true"
android:layout_alignParentRight="true"
android:layout_margin="@dimen/fab_margin"
android:src="@drawable/ic_add_pet"/>
将 FloatingActionButton 的 ID 设置为 fab,圆形按钮的位置放在屏幕的右下角,距离边缘 16dp。由于 FloatingActionButton 是 ImageView 的子类,所以其显示图标可以通过 android:src
属性设置;其余特性与 Button 类似,例如在 Pets App 中,设置其 OnClickListener
动作为打开 EditorActivity。
In CatalogActivity.java
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_catalog);
// Setup FAB to open EditorActivity
FloatingActionButton fab = (FloatingActionButton) findViewById(R.id.fab);
fab.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View view) {
Intent intent = new Intent(CatalogActivity.this, EditorActivity.class);
startActivity(intent);
}
});
...
}
Spinner
Pets App 使用了 Spinner 作为输入宠物性别的选项,它是一个单选菜单,默认状态下会显示默认值和一个向下箭头的图标。
In activity_editor.xml
<Spinner
android:id="@+id/spinner_gender"
android:layout_height="48dp"
android:layout_width="wrap_content"
android:paddingRight="16dp"
android:spinnerMode="dropdown"/>
将 Spinner 的 ID 设置为 spinner_gender,通过 android:spinnerMode
属性设置用户在点击 Spinner 时的展开方式是默认的下拉菜单 (dropdown) 还是弹出对话框 (dialog)。
In EditorActivity.java
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_editor);
...
mGenderSpinner = (Spinner) findViewById(R.id.spinner_gender);
setupSpinner();
}
/**
* Setup the dropdown spinner that allows the user to select the gender of the pet.
*/
private void setupSpinner() {
// Create adapter for spinner. The list options are from the String array it will use
// the spinner will use the default layout
ArrayAdapter genderSpinnerAdapter = ArrayAdapter.createFromResource(this,
R.array.array_gender_options, android.R.layout.simple_spinner_item);
// Specify dropdown layout style - simple list view with 1 item per line
genderSpinnerAdapter.setDropDownViewResource(android.R.layout.simple_dropdown_item_1line);
// Apply the adapter to the spinner
mGenderSpinner.setAdapter(genderSpinnerAdapter);
// Set the integer mSelected to the constant values
mGenderSpinner.setOnItemSelectedListener(new AdapterView.OnItemSelectedListener() {
@Override
public void onItemSelected(AdapterView<?> parent, View view, int position, long id) {
String selection = (String) parent.getItemAtPosition(position);
if (!TextUtils.isEmpty(selection)) {
if (selection.equals(getString(R.string.gender_male))) {
mGender = PetEntry.GENDER_MALE;
} else if (selection.equals(getString(R.string.gender_female))) {
mGender = PetEntry.GENDER_FEMALE;
} else {
mGender = PetEntry.GENDER_UNKNOWN;
}
}
}
// Because AdapterView is an abstract class, onNothingSelected must be defined
@Override
public void onNothingSelected(AdapterView<?> parent) {
mGender = PetEntry.GENDER_UNKNOWN;
}
});
}
- 在
onCreate
通过findViewById
找到 Spinner 对象,并通过辅助方法setupSpinner()
设置 Spinner 适配器和监听器。 - Spinner 适配器设置为一个 ArrayAdapter,通过其静态方法
createFromResource
创建,输入参数分别为应用环境 (Context)、Array 资源 ID、布局资源 ID。其中,布局资源 ID 使用 Android 提供的默认布局simple_spinner_item
;Array 资源 ID 则是在应用内定义的资源。
In res/values/arrays.xml
<resources>
<!-- These are the options displayed in the gender drop-down Spinner -->
<string-array name="array_gender_options">
<item>@string/gender_unknown</item>
<item>@string/gender_male</item>
<item>@string/gender_female</item>
</string-array>
</resources>
创建 Spinner 的 ArrayAdapter 适配器后,调用
setDropDownViewResource
设置菜单的布局,其中 Android 提供的布局simple_dropdown_item_1line
为每行显示一个项目的 ListView。Spinner 的
AdapterView.OnItemSelectedListener
需要 override 两个方法,通过onItemSelected
设置用户选择不同项目时的对应指令,其中应用了TextUtils.isEmpty
来判断当前选项是否为空,增强代码的鲁棒性;另外,通过onNothingSelected
设置没有项目选中时的指令。