数据存储
本章介绍 Android 4 种存储方式的用法,包括共享参数 SharedPerferences、数据库 SQLite、存储卡文件、App 的全局内存,另外介绍 Android 重要组件——应用 Application 的基本概念与常见用法。最后,结合本章所学的知识演示实战项目“购物车”的设计与实现。
共享参数 SharedPreferences
本节介绍 Android 的键值对存储方式——共享参数 SharedPreferences 的使用方法,包括:如何将数据保存到共享参数,如何从共享参数读取数据,如何使用共享参数实现登录页面的记住密码功能,如何利用设备浏览器找到共享参数文件。
共享参数的用法
SharedPreferences 是 Android 的一个轻量级存储工具,它采用的存储结构是 Key-Value 的键值对方式,类似于 Java 的 Properties,二者都是把 Key-Value 的键值对保存在配置文件中。不同的是,Properties 的文件内容形如 Key=Value,而 SharedPreferences 的存储介质是 XML 文件,且以 XML 标记保存键值对。保存共享参数键值对信息的文件路径为:/data/data/应用包名/shared_prefs/文件名.xml。下面是一个共享参数的 XML 文件例子:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="name">Mr Lee</string>
<int name="age" value="30"/>
<boolean name="married" value="true"/>
<float name=="weight" value="100.0"/>
</map>基于 XML 格式的特点,共享参数主要用于以下场合:
- 简单且孤立的数据。若是复杂且相互关联的数据,则要保存于关系数据库。
- 文本形式的数据。若是二进制数据,则要保存至文件。
- 需要持久化存储的数据。App 退出后再次启动时,之前保存的数据仍然有效。
实际开发中,共享参数经常存储的数据包括:App 的个性化配置信息、用户使用 App 的行为信息、临时需要保存的片段信息等。
共享参数对数据的存储和读取操作类似于 Map,也有存储数据的 put 方法,以及读取数据的 get 方法。调用 getSharedPreferences 方法可以获得共享参数实例,获取代码示例如下:
// 从 share.xml 获取共享参数实例
SharedPreferences shared = getSharedPreferences("share", MODE_PRIVATE);由以上代码可知,getSharedPreferences 方法的第一个参数是文件名,填 share 表示共享参数的文件名是 share.xml;第二个参数是操作模式,填 MODE_PRIVATE 表示私有模式。
往共享参数存储数据要借助于 Editor 类,保存数据的代码示例如下:
(完整代码见 chapter06\src\main\java\com\example\chapter06\ShareWriteActivity.java)
SharedPreferences.Editor editor = preferences.edit();
editor.putString("name", name);
editor.putInt("age", Integer.parseInt(age));
editor.putFloat("height", Float.parseFloat(height));
editor.putFloat("weight", Float.parseFloat(weight));
editor.putBoolean("married", ck_married.isChecked());
// editor.commit(); // 用 apply 代替
editor.apply();从共享参数读取数据相对简单,直接调用共享参数实例的getXX方法即可读取键值,注意getXX方法的第二个参数表示默认值,读取数据的代码示例如下:
(完整代码见 chapter06\src\main\java\com\example\chapter06\ShareReadActivity.java)
String name = shared.getString("name", ""); // 从共享参数获取名为 name 的字符串
int age = shared.getInt("age", 0); // 从共享参数获取名为 age 的整型数
boolean married = shared.getBoolean("married", false); // 从共享参数获取名为 married 的布尔数
float weight = shared.getFloat("weight", 0); // 从共享参数获取名为 weight 的浮点数下面通过测试页面演示共享参数的存取过程,先在编辑页面录入用户注册信息,点击保存按钮把数据提交至共享参数,如图 6-1 所示。再到查看页面浏览用户注册信息,App 从共享参数中读取各项数据,并将注册信息显示在页面上,如图 6-2 所示。


实现记住密码的功能
上一章末尾的实战项目,登录页面下方有一个“记住密码”复选框,当时只是为了演示控件的用法,并未真正记住密码。因为用户推出后重新进入登录页面,App 没有回忆起上次的登陆密码。现在利用共享参数改造该项目,使之实现记住密码的功能。
改造内容主要有下列 3 处:
- 声明一个共享参数对象,并在 onCreate 中调用 getSharedPreferences 方法获取共享参数的实例。
- 登录成功时,如果用户勾选了“记住密码”,就使用共享参数保存手机号码与密码。也就是在 loginSuccess 方法中增加以下代码:
(完整代码见 chapter06\src\main\java\com\example\chapter06\LoginShareActivity.java)
// 如果勾选了"记住密码",就把手机号码和密码都保存到共享参数中
if (isRemember) {
SharedPreferences.Editor editor = mShared.Edit(); //获得编辑器对象
editor.putString("phone", et_phone.getText().toString()); // 添加名叫 phone 的手机号码
editor.putString("password", et_password.getText().toString()); // 添加名叫 password 的密码
editor.commit(); // 提交编辑器中的修改
}- 再次打开登录页面时,App 从共享参数读取手机号码与密码,并自动填入编辑框。也就是在 onCreate 方法中增加以下代码:
// 从 share_login.xml 获取共享参数对象
mShared = getSharedPreferences("config", MODE_PRIVATE);
// 获取共享参数保存的手机号码
String phone = mShared.getString("phone", "");
// 获取共享参数保存的密码
String password = mShared.getString("password", "");
et_phone.setText(phone); // 往手机号码编辑框填写上次保存的手机号
et_password.setText(password); // 往密码编辑框填写上次保存的密码代码修改完毕,只要用户上次登录成功时勾选“记住密码”,下次进入登录页面后 App 就会自动填写上次登录的手机号码与密码。具体的效果如图 6-3 和图 6-4 所示。其中,图 6-3 为用户首次登录成功的界面,此时勾选了“记住密码”;图 6-4 为用户再次进入登录的界面,因为上次登录成功时已经记住密码,所以这次页面会自动填充保存的登录信息。


利用设备浏览器寻找共享参数文件
前面的“共享参数的基本用法”提到,参数文件的路径为/data/data/应用包名/shared_prefs/xxx.xml,然而使用手机自带的文件管理器却找不到该路径,data 下面只有空目录而已。这是因为手机厂商加了层保护,不让用户查看 App 的核心文件,否则万一不小心误删了,App 岂不是运行报错了?当然作为开发者,只要打开了手机的 USB 调试功能,还是有办法拿到测试应用的数据文件。首先打开 Android Studio,依次选择菜单 Run->Run 'xxx',把测试应用比如 chapter06 安装到手机上。接着单击 Android Studio 左下角的 logcat 标签,找到已连接的手机设备和测试应用,如图 6-5 所示。

注意到 logcat 窗口的右边,也就是 Android Studio 右侧有个竖排标签“Device File Explorer”,翻译过来叫设备文件浏览器。单击该标签按钮,此时主界面右边弹出名为“Device File Explorer”的窗口,如图 6-6 所示。

在图 6-6 的窗口中依次展开各级目录,进到/data/data/com.dongnaoedu.chapter06/shared_prefs目录,在该目录下看到了参数文件 config.xml。右击 config.xml,并在右键菜单中选择“Save As”,把该文件保存到电脑中,之后就能查看详细的文件内容了。不仅参数文件,凡是保存在“/data/data/应用包名/”下面的所有文件,均可利用设备浏览器导出至电脑,下一节将要介绍的数据库 db 文件可按照以上步骤导出。
数据库 SQLite
本节介绍 Android 的数据库存储方式——SQLite 的使用方法,包括:SQLite 用到了哪些 SQL 语法,如何使用数据库管理器操纵 SQLite,如何使用数据库帮助器简化数据库操作等,以及如何利用 SQLite 改进登录页面的记住密码功能。
SQL 的基本用法
SQL 本质上是一种编程语言,它的学名叫作“结构化查询语言”(全称为 Structured Query Language,简称 SQL)。不过 SQL 语言并非通用的编程语言,它专用于数据库的访问和处理,更像是一种操作命令,所以常说 SQL 语句而不说 SQL 代码。标准的 SQL 语句分为 3 类:数据定义、数据操纵和数据控制,但不同的数据库往往有自己的实现。
SQLite 是一种小巧的嵌入式数据库,使用方便、开发简单。如同 MySQL、Oracle 那样,SQLite 也采用 SQL 语句管理数据,由于它属于轻型数据库,不涉及复杂的数据控制操作,因此 App 开发只用到数据定义和数据操纵两类 SQL。此外,SQLite 的 SQL 语法与通用的 SQL 语法略有不同,接下来介绍的两类 SQL 语法全部基于 SQLite。
数据定义语言
数据定义语言全程 Data Definition Language,简称 DDL,它描述了怎样变更数据实体的框架结构。就 SQLite 而言,DDL 语言主要包括 3 种操作:创建表格、删除表格、修改表结构,分别说明如下。
- 创建表格
表格的创建动作由 create 命令完成,格式为CREATE TABLE IF NOT EXISTS 表格名称(以逗号分隔的各字段定义);。以用户信息表为例,它的建表语句如下所示:
CREATE TABLE IF NOT EXISTS user_info
(
_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
name VARCHAR NOT NULL,
age INTEGER NOT NULL,
height LONG NOT NULL,
weight FLOAT NOT NULL,
married INTEGER NOT NULL,
update_time VARCHAR NOT NULL
);上面的 SQL 语法与其他数据库的 SQL 语法有所出入,相关的注意点说明见下:
- SQL 语句不区分大小写,无论是 create 与 table 这类关键词,还是表格名称、字段名称,都不区分大小写。唯一区分大小写的是被单引号括起来的字符串值。
- 为避免重复建表,应加上 IF NOT EXISTS 关键词,例如 CREATE TABLE IF NOT EXISTS 表格名称...
- SQLite 支持整型 INTEGER、长整型 LONG、字符串 VARCHAR、浮点数 FLOAT,但不支持不二类型。布尔类型的数据要使用整型保存,如果直接保存不二数据,在入库时 SQLite 会自动将它转为 0 或 1,其中 0 表示 false,1 表示 true。
- 建表时需要唯一标识字段,它的字段名为 id。创建新表都要加上该字段定义,例如 id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL。
- 删除表格
表格的删除动作由 drop 命令完成,格式为DROP TABLE IF EXISTS 表格名称;。下面是删除用户信息表的 SQL 语句例子:
DROP TABLE IF EXISTS user_info;- 修改表结构
表格的修改动作由 alter 命令完成,格式为ALTER TABLE 表格名称 修改操作;。对于字段增加操作,需要在 alter 之后补充 add 命令,具体格式如ALTER TABLE 表格名称 ADD COLUMN 字段名称 字段类型;。下面是给用户信息表增加、修改、删除手机号字段的 SQL 语句例子:
ALTER TABLE user_info ADD COLUMN phone VARCHAR;
ALTER TABLE user_info RENAME COLUMN phone TO phone1;
ALTER TABLE user_info DROP COLUMN phone1;注意,SQLite 的 ALTER 语句每次只能添加一列字段,若要添加多列,就得分多次添加。
数据操纵语言
数据操纵语言全称 Data Manipulation Language,简称 DML。它描述了怎样处理数据实体的内部记录。表格记录的操作类型包括添加、删除、修改、查询 4 类,分别说明如下:
- 添加记录
记录的添加动作由 insert 命令完成,格式为INSERT INTO 表格名称(以逗号分隔的字段名列表) VALUES(以逗号分隔的字段值列表);。下面是往用户信息表插入一条记录的 SQL 语句例子:
INSERT INTO user_info(name,age,height,weight,married,update_time) VALUES('张三', 20, 170, 50, 0, '20200504');- 删除记录
记录的删除动作由 delete 命令完成,格式为DELETE FROM 表格名称 WHERE 查询条件;,其中查询条件的表达式形如“字段名=字段值”,多个字段的条件交集通过“AND”连接,条件并集通过“OR”连接。下面是从用户信息表删除指定记录的 SQL 语句例子:
DELETE FROM user_info WHERE name='张三';- 修改记录
记录的修改动作由 update 命令完成,格式为UPDATE 表格名称 SET 字段名=字段值 WHERE 查询条件。下面是对用户信息表更新指定记录的 SQL 语句例子:
UPDATE user_info SET married=1 WHERE name='张三';- 查询记录
记录的查询动作由 select 命令完成,格式为SELECT 以逗号分隔的字段名列表 FROM 表格名称 WHERE 查询条件;。如果字段名列表填星号*,则表示查询该表的所有字段。下面是从用户信息表查询指定记录的 SQL 语句例子:
SELECT name FROM user_info WHERE name='张三';查询操作除了比较字段值条件之外,常常需要对查询结果排序,此时要在查询条件后面添加排序条件,对应的表达式为ORDER BY 字段名 ASC/DESC,意指对查询结果按照某个字段排序,其中 ASC 代表升序,DESC 代表降序。下面是查询记录并对结果排序的 SQL 语句例子:
SELECT * FROM user_info ORDER BY age ASC;如果读者之前不熟悉 SQL 语法,建议下载一个 SQLite 管理软件,譬如 SQLiteStudio,先在电脑上多加练习 SQLite 的常见操作语句。
数据库管理器 SQLiteDatabase
SQL 语句毕竟只是 SQL 命令,若要在 Java 代码中操纵 SQLite,还需专门的工具类。SQLiteDatabase 便是 Android 提供的 SQLite 数据库管理器,开发者可以在活动页面代码调用 openOrCreateDatabase 方法获取数据库实例,参考代码如下:
(完整代码见 chapter06\src\main\java\com\example\chapter06\DatabaseActivity.java)
// 创建或打开数据库。数据库如果不存在就创建它,如果存在就打开它
SQLiteDatabase db = openOrCreateDatabase(mDatabaseName, Context.MODE_PRIVATE, null);
desc = String.format("数据库%s创建%s", db.getPath(), (db != null) ? "成功" : "失败");
tv_database.setText(desc);首次运行测试 App,调用 openOrCreateDatabase 方法会自动创建数据库,并返回该数据库的管理器实例,创建结果如图 6-7 所示。

获得数据库实例之后,就能对该数据库开展各项操作了。数据库管理器 SQLiteDatabase 提供了若干操作数据表的 API,常用的方法有 3 类,列举如下:
- 管理类,用户数据库层面的操作
- openDatabase:打开指定路径的数据库。
- isOpen:判断数据库是否已打开。
- close:关闭数据库。
- getVersion:获取数据库的版本号。
- setVersion:设置数据库的版本号。
- 事务类,用于事务层面的操作
- beginTransaction:开始事务。
- setTransactionSuccessful:设置事务的成功标志。
- endTransaction:结束事务。执行本方法时,系统会判断之前是否调用了 setTransactionSuccessful 方法,如果之前已调用该方法就提交事务,如果没有调用该方法就回滚事务。
- 数据处理类,用于数据表层面的操作
- execSQL:执行拼接好的 SQL 控制语句。一般用于建表、删表、变更表结构。
- delete:删除符合条件的记录。
- update:更新符合条件的记录信息。
- insert: 插入一条记录。
- query:执行查询操作,并返回结果集的游标。
- rawQuery:执行拼接好的 SQL 查询语句,并返回结果集的游标。
在实际开发中,比较经常用到的是查询语句,建议先写好查询操作的 select 语句,再调用 rawQuery 方法执行查询语句。
数据库帮助器 SQLiteOpenHelper
由于 SQLiteDatabase 存在局限性,一不小心就会重复打开数据库,处理数据库的升级也不方便;因此 Android 提供了数据库帮助器 SQLiteOpenHelper,帮助开发者合理使用 SQLite。
SQLiteOpenHelper 的具体使用步骤如下:
步骤一,新建一个继承自 SQLiteOpenHelper 的数据库操作类,按提示重写 onCreate 和 onUpgrade 两个方法。其中,onCreate 方法只在第一次打开数据库时执行,在此可以创建表结构;而 onUpgrade 方法在数据库版本升高时执行,再次可以根据新旧版本号变更表结构。
步骤二,为保证数据库安全使用,需要封装几个必要方法,包括获取单例对象、打开数据库连接、关闭数据库连接,说明如下:
- 获取单例对象:确保在 App 运行过程中数据库只会打开一次,避免重复打开引起错误。
- 打开数据库连接:SQLite 有锁机制,即读锁和写锁的处理;故而数据库连接也分两种,读连接可调用 getReadableDatabase 方法获取,写连接可调用 getWritableDatabase 获得。
- 关闭数据库连接:数据库操作完毕,调用数据库实例的 close 方法关闭连接。
步骤三,提供对表记录增加、删除、修改、查询的操作方法。
能被 SQLite 直接使用的数据结构是 ContentValues 类,它类似于映射 Map,也提供了 put 和 get 方法存取键值对。区别之处在于:ContentValues 的键只能是字符串,不能是其他类型。ContentValues 主要用于增加记录和更新记录,对应数据库的 insert 和 update 方法。
记录的查询操作用到了游标类 Cursor,调用 query 和 rawQuery 方法返回的都是 Cursor 对象,若要获取全部的查询结果,则需根据游标的指示一条一条遍历结果集合。Cursor 的常用方法可分为 3 类,说昆明如下:
- 游标控制类方法,由于指定游标的状态
- close:关闭游标。
- isClosed:判断游标是否关闭。
- isFirst:判断游标是否在开头。
- isLast:判断游标是否在末尾。
- 游标移动类方法,把游标移动到指定位置
- moveToFirst:移动游标到开头。
- moveToLast:移动游标到末尾。
- moveToNext:移动游标到下一条记录。
- moveToPrevious:移动游标到上一条记录。
- move:往后移动游标若干条记录。
- moveToPosition:移动游标到指定位置的记录。
- 获取记录类方法,可获取记录的数量、类型以及取值
- getCount:获取结果记录的数量。
- getInt:获取指定字段的整型值。
- getLong:获取指定字段的长整型值。
- getFloat:获取指定字段的浮点数值。
- getString:获取指定字段的字符串值。
- getType:获取指定字段的字段类型。
鉴于数据库操作的特殊性,不方便单独演示某个功能,接下来从创建数据库开始介绍,完整演示以下数据库的读写操作。用户注册信息的演示页面包括两个,分别是记录保存页面和记录读取页面,其中记录保存页面通过 insert 方法向数据库添加用户信息,完整代码见 chapter06\src\main\java\com\example\chapter06\SQLiteWriteActivity.java;而记录读取页面通过 query 方法从数据库读取用户信息,完整代码见 chapter06\src\main\java\com\example\chapter06\SQLiteReadActivity.java。
运行测试 App,先打开记录保存页面,一次录入并将两个用户的注册信息保存至数据库,如图 6-8 和图 6-9 所示。再打开记录读取页面,从数据库读取用户注册信息并展示在页面上,如图 6-10 所示。



上述演示页面主要用到了数据库记录的添加、查询和删除操作,对应的数据库帮助器关键代码如下所示,尤其关注里面的 insert、delete、update 和 query 方法:
(完整代码见 chapter06\src\main\java\com\example\chapter06\database\UserDBHelper.java)
public class UserDBHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "user.db";
private static final String TABLE_NAME = "user_info";
private static final int DB_VERSION = 2;
private static UserDBHelper mHelper = null;
private SQLiteDatabase mRDB = null;
private SQLiteDatabase mWDB = null;
private UserDBHelper(Context context) {
super(context, DB_NAME, null, DB_VERSION);
}
// 利用单例模式获取数据库帮助器的唯一实例
public static UserDBHelper getInstance(Context context) {
if (mHelper == null) {
mHelper = new UserDBHelper(context);
}
return mHelper;
}
// 打开数据库的读连接
public SQLiteDatabase openReadLink() {
if (mRDB == null || !mRDB.isOpen()) {
mRDB = mHelper.getReadableDatabase();
}
return mRDB;
}
// 打开数据库的写连接
public SQLiteDatabase openWriteLink() {
if (mWDB == null || !mWDB.isOpen()) {
mWDB = mHelper.getWritableDatabase();
}
return mWDB;
}
// 关闭数据库连接
public void closeLink() {
if (mRDB != null && mRDB.isOpen()) {
mRDB.close();
mRDB = null;
}
if (mWDB != null && mWDB.isOpen()) {
mWDB.close();
mWDB = null;
}
}
// 创建数据库,执行建表语句
@Override
public void onCreate(SQLiteDatabase db) {
String sql = "CREATE TABLE IF NOT EXISTS " + TABLE_NAME + " (" +
"_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL," +
" name VARCHAR NOT NULL," +
" age INTEGER NOT NULL," +
" height LONG NOT NULL," +
" weight FLOAT NOT NULL," +
" married INTEGER NOT NULL," +
" update_time VARCHAR NOT NULL);";
db.execSQL(sql);
}
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
String sql = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN phone VARCHAR;";
db.execSQL(sql);
sql = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN password VARCHAR;";
db.execSQL(sql);
}
public long insert(User user) {
ContentValues values = new ContentValues();
values.put("name", user.name);
values.put("age", user.age);
values.put("height", user.height);
values.put("weight", user.weight);
values.put("married", user.married);
values.put("update_time", user.updateTime);
// 执行插入记录动作,该语句返回插入记录的行号
// 如果第三个参数values 为Null或者元素个数为0, 由于insert()方法要求必须添加一条除了主键之外其它字段为Null值的记录,
// 为了满足SQL语法的需要, insert语句必须给定一个字段名 ,如:insert into person(name) values(NULL),
// 倘若不给定字段名 , insert语句就成了这样: insert into person() values(),显然这不满足标准SQL的语法。
// 如果第三个参数values 不为Null并且元素的个数大于0 ,可以把第二个参数设置为null 。
//return mWDB.insert(TABLE_NAME, null, values);
try {
mWDB.beginTransaction();
mWDB.insert(TABLE_NAME, null, values);
//int i = 10 / 0;
mWDB.setTransactionSuccessful();
} catch (Exception e) {
e.printStackTrace();
} finally {
mWDB.endTransaction();
}
return 1;
}
public long deleteByName(String name) {
//删除所有
//mWDB.delete(TABLE_NAME, "1=1", null);
return mWDB.delete(TABLE_NAME, "name=?", new String[]{name});
}
public void deleteAll() {
//删除所有
mWDB.execSQL("DELETE FROM " + TABLE_NAME);
// return mWDB.delete(TABLE_NAME, "1=1", null);
// return mWDB.delete(TABLE_NAME, "name=?", new String[]{name});
}
public long update(User user) {
ContentValues values = new ContentValues();
values.put("name", user.name);
values.put("age", user.age);
values.put("height", user.height);
values.put("weight", user.weight);
values.put("married", user.married);
values.put("update_time", user.updateTime);
return mWDB.update(TABLE_NAME, values, "name=?", new String[]{user.name});
}
public List<User> queryAll() {
List<User> list = new ArrayList<>();
// 执行记录查询动作,该语句返回结果集的游标
Cursor cursor = mRDB.query(TABLE_NAME, null, null, null, null, null, null);
// 循环取出游标指向的每条记录
while (cursor.moveToNext()) {
User user = new User();
user.id = cursor.getInt(0);
user.name = cursor.getString(1);
user.age = cursor.getInt(2);
user.height = cursor.getLong(3);
user.weight = cursor.getFloat(4);
//SQLite没有布尔型,用0表示false,用1表示true
user.married = (cursor.getInt(5) == 0) ? false : true;
user.updateTime = cursor.getString(6);
list.add(user);
}
return list;
}
public List<User> queryByName(String name) {
List<User> list = new ArrayList<>();
// 执行记录查询动作,该语句返回结果集的游标
Cursor cursor = mRDB.query(TABLE_NAME, null, "name=?", new String[]{name}, null, null, null);
// 循环取出游标指向的每条记录
while (cursor.moveToNext()) {
User user = new User();
user.id = cursor.getInt(0);
user.name = cursor.getString(1);
user.age = cursor.getInt(2);
user.height = cursor.getLong(3);
user.weight = cursor.getFloat(4);
//SQLite没有布尔型,用0表示false,用1表示true
user.married = (cursor.getInt(5) == 0) ? false : true;
list.add(user);
}
return list;
}
}优化记住密码功能
在“实现记住密码功能”中,虽然使用共享参数实现了记住密码功能,但是该方案只能记住一个用户的登录信息,并且手机号码跟密码没有对应关系,如果换个手机号码登录,前一个用户的登录信息就被覆盖了。真正的记住密码功能应当是这样的:先输入手机号码,然后根据手机号码匹配保存的密码,一个手机号码对应一个密码,从而实现具体手机号码的密码记忆功能。
现在运用数据库技术分条存储各用户的登录信息,并支持根据手机号查找登录信息,从而同时记住多个手机号的密码。具体的改造主要有下列 3 点:
- 声明一个数据库的帮助器对象,然后在活动页面的 onResume 方法中打开数据库连接,在 onPause 方法中关闭数据库连接,示例代码如下:
(完整代码见 chapter06\src\main\java\com\example\chapter06\LoginSQLiteActivity.java)
private UserDBHelper mHelper; // 声明一个用户数据库的帮助器对象
@Override
protected void onResume() {
super.onResume();
mHelper = UserDBHelper.getIntance(this, 1); // 获得用户数据库帮助器的实例
mHelper.openWriteLink(); // 恢复页面,则打开数据库连接
}
@Override
protected void onPause() {
super.onPause();
mHelper.closeLink(); // 暂停页面,则关闭数据库连接
}- 登录成功时,如果用户勾选了“记住密码”,就将手机号码及其密码保存至数据库。也就是在 loginSuccess 方法中增加如下代码:
// 如果勾选了“记住密码”,则把手机号码和密码保存为数据库的用户表记录
if (isRemember) {
UserInfo info = new UserInfo(); // 创建一个用户信息对象
info.phone = et_phone.getText().toString();
info.password = et_password.getText().toString();
info.update_time = DateUtil.getNowDateTime("yyyy-MM-dd HH:mm:ss");
mHelper.insert(info); // 往用户数据库添加登录成功的用户信息
}- 再次打开登录页面,用户输入手机号再点击密码框的时候,App 根据手机号到数据库查找登录信息,并将记录结果中的密码填入密码框。其中根据手机号码查找登录信息,要求在帮助器代码中添加以下方法,用于找到指定手机的登录密码:
// 根据手机号码查询指定记录
public UserInfo queryByPhone(String phone) {
UserInfo info = null;
List<UserInfo> infoList = query(String.format("phone='%s'", phone));
if (infoList.size() > 0) { // 存在该号码的登录信息
info = infoList.get(0);
}
return info;
}此外,上面第 3 点的点击密码框出发查询操作,用到了编辑框的焦点变更事件,有关焦点变更监听器的详细用法参见第 5 章的“焦点变更监听器”。就本案例而言,光标切到密码框触发焦点变更事件,具体处理逻辑要求重写监听器的 onFocusChange 方法,重写后的方法代码如下所示:
@Override
public void onFocusChange(View v, boolean hasFocus) {
String phone = et_phone.getText().toString();
// 判断是否是密码编辑框发生焦点变化
if (v.getId() == R.id.et_password) {
// 用户已输入手机号码,且密码框获得焦点
if (phone.length() > 0 && hasFocus) {
// 根据手机号码到数据库中查询用户记录
UserInfo info = mHelper.queryByPhone(phone);
if (info != null) {
// 找到用户记录,则自动在密码框中填写该用户的密码
et_password.setText(info.password);
}
}
}
}重新运行测试 App,先打开登录页面,勾选“记住密码”,并确保本次登录成功。然后再次进入登录页面,输入手机号码后光标还停留在手机框,如图 6-11 所示。接着点击密码框,光标随之跳到密码框,此时密码框自动填入了该号码对应的密码串,如图 6-12 所示。由效果图可见,这次实现了真正意义上的记住密码功能。


存储卡的文件操作
本节介绍 Android 的文件存储方式——在存储卡上读写文件,包括:公有存储控件与私有存储空间有什么区别、如何利用存储卡读写文本文件、如何利用存储卡读写图片文件等。
私有存储空间与公共存储空间
为了更规范地管理手机存储空间,Android 从 7.0 开始将存储卡划分为私有存储和公共存储两大部分,也就是分区存储方式,系统给每个 App 都分配了默认的私有存储空间。App 在私有空间上读写文件无须任何授权,但是若想在公共空间读写文件,则要在 AndroidManifest.xml 里面添加下述的权限配置。
<!-- 存储卡读写 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />但是即使 App 声明了完整的存储卡操作权限,系统仍然默认禁止该 App 访问公共空间。打开手机的系统设置界面,进入到具体应用的管理页面,会发现该应用的存储访问权限被禁止了,如图 6-13 所示。

当然图示的禁止访问只是不让访问存储卡的公共空间,App 自身的私有空间依旧可以正常读写。这缘于 Android 把存储卡分成了两块区域,一块是所有应用均可访问的公共空间,另一块是只有应用自己才可以访问的专享空间。虽然 Android 给每个应用都分配了单独的安装目录,但是安装目录的空间很紧张,所以 Android 在存储卡的“Android/data”目录下给每个应用又单独建了一个文件目录,用来保存应用自己需要处理的临时文件,这个目录只有当前应用才能够读写文件,其他应用是不允许读写的。由于私有空间本身已经加了访问权限控制,因此它不受系统禁止访问的影响。应用操作的及文件目录自然不成问题。因为私有的文件目录只有属主应用才能访问,所以一旦属主应用被卸载,那么对应的目录也会被删掉。
既然存储卡分为公共空间和私有空间两部分,它们的空间路径获取也就有所不同。若想获取公共空间的存储路径,调用的是 Environment.getExternalStoragePublicDirectory 方法;若想获取应用私有空间的存储路径,调用的是 getExternalFilesDir 方法。下面是分别获取两个空间路径的代码例子:
(完整代码见 chapter06\src\main\java\com\example\chapter06\FilePathActivity.java)
// 获取系统的公共存储路径
String publicPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).toString();
// 获取当前 App 的私有存储路径
String privatePath = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString();
boolean isLegacy = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android 10 的存储空间默认采取分区方式,此处判断是传统方式还是分区方式
isLegacy = Environment.isExternalStorageLegacy();
}
String desc = MessageFormat.format("系统的公共存储路径位于 {0}\n" +
"当前 App 的私有存储路径位于 {1}\n" +
"Android7.0 之后默认禁止访问公共存储目录\n" +
"当前 App 的存储空间采取 {2}", publicPath, privatePath, isLegacy ? "传统方式" : "分区方式");
tv_txt.setText(desc);该例子运行之后获得的路径信息如图 6-14 所示,可见应用的私有空间路径位于存储卡根目录/Android/data/应用包名/files/Download这个目录中。

在存储卡上读写文本文件
文本文件的读写借助于文件 IO 流 FileOutputStream 和 FileInputStream。其中,FileOutputStream 用于写文件,FileInputStream 用于读文件,它们读写文件的代码例子如下:
(完整代码见 chapter06\src\main\java\com\example\chapter06\util\FileUtil.java)
// 把字符串保存到指定路径的文本文件
public static void saveText(String path, String txt) {
// 根据指定的文件路径构建文件输出流对象
try (FileOutputStream fos = new FileOutputStream(path)) {
fos.write(txt.getBytes()); // 把字符串写入文件输出流
} catch(Exception e) {
e.printStackTrace();
}
}
// 从指定路径的文本文件中读取内容字符串
public static String openText(String path) {
String readStr = "";
// 根据指定的文件路径构建文件输出流对象
try (FileOutputStream fos = new FileOutputStream(path)) {
byte[] b = new byte[fis.available()];
fis.read(b); // 从文件输入流读取字节数组
readStr = new String(b); // 把字节数组转换为字符串
} catch(Exception e) {
e.printStackTrace();
}
return readStr;
}接着分别创建写文件页面和读文件页面,其中写文件页面调用 saveText 方法保存文本,完整代码见 chapter06\src\main\java\com\example\chapter06\FileWriteActivity.java;而读文件页面调用 readText 方法从指定路径的文件中读取文本内容,完整代码见 chapter06\src\main\java\com\example\chapter06\FileReadActivity.java。
然后运行测试 App,先打开文本写入页面,录入注册信息后保存为私有目录里的文本文件,此时写入界面如图 6-15 所示,再打开文本读取页面,App 自动在私有目录下找到文本文件列表,并展示其中一个文件的文本内容,此时读取界面如图 6-16 所示。


在存储卡上读写图片文件
文本文件读写可以转换为对字符串的读写,而图片文件保存的是图像数据,需要专门的位图工具 Bitmap 处理。位图对象依据来源不同又分成 3 种获取方式,分别对应位图工厂 BitmapFactory 的下列 3种方法:
- decodeResource:从指定的资源文件中获取位图数据。例如下面代码表示从资源文件 huawei.png 获取位图对象:
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.huzwei);- decodeFile:从指定路径的文件中获取位图数据。注意从 Android 10 开始,该方法只适用于私有目录下的图片,不适用公共空间下的图片。
- decodeStream:从指定的输入流中获取位图数据。比如使用 IO 流打开图片文件,此时文件输入流对象即可作为 decodeStream 方法的入参,相应的图片读取代码如下:
(完整代码见 chapter06\src\main\java\com\example\chapter06\util\FileUtil.java)
// 从指定路径的图片文件中读取位图数据
public static Bitmap openImage(String path) {
Bitmap bitmap = null; // 声明一个位图对象
// 根据指定的文件路径构建文件输入流对象
try (FileInputStream fis = new FileInputStream(path)) {
bitmap = BitmapFactory.decodeStream(fis); // 从文件输入流中解码位图数据
} catch(Exception e) {
e.printStackTrace();
}
}得到位图对象之后,就能在图像视图上显示位图。图像视图 ImageView 提供了下列方法显示各种来源的图片:
- setImageResource:设置图像视图的图片资源,该方法的入参为资源图片的编号,形如
R.drawable.去掉扩展名的图片名称。 - setImageBitmap:设置图像视图的位图对象,该方法的入参为 Bitmap 类型。
- setImageURI:设置图像视图的路径对象,该方法的入参为 Uri 类型。字符串格式的文件路径可通过代码
Uri.parse(file_path)转换成路径对象。
读取图片文件的花样倒是挺多,把位图数据写入图片文件却只有一种,即通过位图对象的 compress 方法将位图数据压缩到文件输出流。具体的图片写入代码如下所示:
// 把位图数据保存到指定路径的图片文件中
public static void saveImage(String path, Bitmap bitmap) {
try (FileOutputStream fos = new FileOutputStream(path)) {
// 根据指定的文件路径构建文件输出流对象
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, fos);
} catch(Exception e) {
e.printStackTrace();
}
}接下来完整演示一遍图片文件的读写操作,首先创建图片写入页面,从某个资源图片读取位图数据,再把位图数据保存为私有目录的图片文件,相关代码示例如下:
(完整代码见 chapter06\src\main\java\com\example\chapter06\ImageWriteActivity.java)
// 获取当前 App 的私有下载目录
String path = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + "/";
// 获得指定目录下面的所有图片文件
mFilelist = FileUtil.getFileList(mPath, new String[]{".jpeg"});
if (mFilelist.size() > 0) {
// 打开并显示选中的图片文件内容
String file_path = mFilelist.get(0).getAbsolutePath();
tv_content.setText("找到最新的图片文件,路径为" + file_path);
// 显示存储卡图片文件的第一种方式:直接调用 setImageURI 方法
// iv_content.setImageURI(Uri.parse(file_path)); // 设置图像视图的路径对象
// 第二种方式:先调用 BitmapFactory.decodeFile 获得位图,再调用 setImageBitmap 方法
// Bitmap bitmap = BitmapFactory.decodeFile(file_path);
// iv_content.setImageBitmap(bitmap); // 设置图像视图的位图对象
// 第三种方式:先调用 FileUtil.openImage 获得位图,再调用 setImageBitmap 方法
Bitmap bitmap = FileUtil.openImage(file_path);
iv_content.setImageBitmap(bitmap); // 设置图像视图的位图对象
}运行测试 App,先打开图片写入页面,点击保存按钮把资源图片保存到存储卡,此时写入界面如图 6-17 所示。再打开图片读取页面,App 自动在私有目录下找到图片文件列表,并展示其中一张图片,此时读取界面如图 6-18 所示。


应用组件 Application
本节介绍 Android 重要组件 Application 的基本概念和常见用法。首先说明 Application 的生命周期贯穿了 App 的整个运行过程,接着利用 Application 实现 App 全局变量的读写,然后阐述了如何借助 App 实例来操作 Room 数据库框架。
Application 的生命周期
Application 是 Android 的一大组件,在 App 运行过程中有且仅有一个 Application 对象贯穿应用的整个生命周期。打开 AndroidManifest.xml,发现 activity 节点的上级正是 application 节点,不过该节点并未指定 name 属性,此时 App 采用默认的 Application 实例。
注意到每个 activity 节点都指定了 name 属性,譬如常见的 name 属性值为 .MainActivity,让人知晓该 activity 的入口代码是 MainActivity.java。现在尝试给 application 节点加上 name 属性,看看其庐山真面目,具体步骤说明如下:
- 打开 AndroidManifest.xml,给 application 节点加上 name 属性,表示 application 的入口代码是 MainApplication.java。修改后的 application 节点示例如下:
(完整代码见 chapter06\src\main\AndroidManifest.xml)
<application
android:name=".MainApplication"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:theme="@style/AppTheme"
/>- 在 java 代码的包名目录下创建 MainApplication.java,要求该类继承 Application,继承之后可供重写的方法主要有以下 3 个。
- onCreate:在 App 启动时调用。
- onTerminate:在 App 终止时调用(按字面意思)。
- onConfigurationChanged:在配置改变时调用,例如从竖屏变为横屏。
光看字面意思的话,与生命周期有关的方法是 onCreate 和 onTerminate,那么重写这两个方法,并在重写后的方法中打印日志,修改后的 java 代码如下所示:
(完整代码见 chapter06\src\main\java\com\example\chapter06\MainApplication.java)
public class MainApplication extends Application {
@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "onCreate");
}
@Override
public void onTerminate() {
super.onTerminate();
Log.d(TAG, "onTerminate");
}
}- 运行测试 App,在 logcat 窗口观察应用日志。但是只在启动一开始看到 MainApplication 的 onCreate 日志(该日志先于 MainActivity 的 onCreate 日志),却始终无法看到它的 onTerminate 日志,无论是自行退出 App 还是强行杀掉 App,日志都不会打印 onTerminate。
无论你怎么折腾,这个 onTerminate 日志都不会出来。Android 明明提供了这个方法,同时提供了关于该方法的解释,说明文字如下:This method is for use in emulated process environments. It will never be called on a production Android device, where processes are removed by simply killing them; no user code(including this callback) is executed when doing so。这段话的意思是:该方法供模拟环境使用,它在真机上永远不会被调用,无论是直接杀进程还是代码退出;执行该操作时,不会执行任何用户代码。
现在很明确了,onTerminate 方法就是个摆设,中看不中用。如果读者想在 App 退出前回收系统资源,就不能指望 onTerminate 方法的回调了。
利用 Application 操作全局变量
C/C++ 有全局变量的概念,因为全局变量保存在内存中,所以操作全局变量就是操作内存,显然内存的读写速度远比读写数据库或读写文件快得多。所谓全局,指的是其他代码都可以引用该变量,因此全局变量是共享数据和消息传递的好帮手。不过 Java 没有全局变量的概念,与之比较接近的是类里面的静态成员变量,该变量不但能被外部直接引用,而且它在不同地方引用的值是一样的(前提是在引用期间不能改动变量值),所以借助静态成员变量也能实现类似全局变量的功能。
根据上一小节的介绍可知,Application 的生命周期覆盖了 App 运行的全过程。不像短暂的 Activity 生命周期,一旦退出该页面,Activity 实例就被销毁。因此,利用 Application 的全生命特性,能够在 Application 实例中保存全局变量。
适合在 Application 中保存的全局变量主要有下面 3 类数据:
- 会频繁读取的信息,例如用户名、手机号码等。
- 不方便由意图传递的数据,例如位图对象、非字符串类型的集合对象等。
- 容易因频繁分配内存而导致内存泄漏的对象,例如 Handler 处理器实例等。
要想通过 Application 实现全局内存的读写,得完成以下 3 项工作:
- 编写一个继承自 Application 的新类 MainApplication。该类采用单例模式,内部先生命自身类的一个静态对象,在创建 App 时把自身赋值给这个静态对象,然后提供该对象的获取方法 getIntance。具体实现代码示例如下:
(完整代码见 chapter06\src\main\java\com\example\chapter06\MainApplication.java)
public class MainApplication extends Application {
private final static String TAG = "MainApplication";
private static MainApplication mApp; // 声明一个当前应用的静态实例
// 声明一个公共的信息映射对象,可当作全局变量使用
public HashMap<String, String> infoMap = new HashMap<String, String>();
// 利用单例模式获取当前应用的唯一实例
public static MainApplication getInstance() {
return mApp;
}
@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "onCreate");
mApp = this; // 在打开应用时对静态的应用实例赋值
}
}在活动页面代码中调用 MainApplication 的 getInstance 方法,获得它的一个静态对象,再通过该对象访问 MainApplication 的公共变量和公共方法。
不要忘了在 AndroidManifest.xml 中注册新定义的 Application 类名,也就是给 application 节点增加 android:name 属性,其值为 .MainApplication。
接下来演示如何读写内存中的全局变量,首先分别创建写内存页面和读内存页面,其中写内存页面把用户的注册信息保存到全局变量 infoMap,完整代码见 chapter06\src\main\java\com\example\chapter06\AppWriteActivity.java;而读内存页面从全局变量 infoMap 读取用户的注册信息,完整代码见 chapter06\src\main\java\com\example\chapter06\AppReadActivity.java。
然后运行测试 App,先打开内存写入页面,录入注册信息后保存至全局变量,此时写入界面如图 6-19 所示。再打开内存读取页面,App 自动从全局变量获取注册信息,并展示拼接后的信息文本,此时读取界面如图 6-20 所示。


利用 Room 简化数据库操作
虽然 Android 提供了数据库帮助器,但是开发者在进行数据库编程时仍有诸多不便,比如每次增加一张新表,开发者都得手工实现以下代码逻辑:
- 重写数据库帮助器的 onCreate 方法,添加该表的建表语句。
- 在插入记录之时,必须将数据实例的属性值逐一赋给该表的各字段。
- 在查询记录之时,必须便利结果集游标,把各字段值逐一赋给数据实例。
- 每次读写操作之前,都要先开启数据库连接;读写操作之后,又要关闭数据库连接。
上述的处理操作无疑存在不少重复劳动,数年来引得开发者叫苦连连。为此各类数据库处理框架纷纷涌现,包括 GreenDao、OrmLite、Realm 等,可谓百花齐放。眼见 SQLite 渐渐乏人问津,谷歌公司干脆整了个自己的数据库框架——Room,该框架同样基于 SQLite,但它通过注解技术极大地简化了数据库操作,减少了原来相当一部分编码工作量。
由于 Room 并未集成到 SDK 中,而是作为第三方框架提供,因此要修改模块的 build.gradle 文件,往 dependencies 节点添加下面两行配置,表示导入指定版本的 Room 库:
implementation 'androidx.room:room-runtime:2.2.5'
annotationProcessor 'androidx.room:room-compiler:2.2.5'导入 Room 库之后,还要编写若干对应的代码文件,以录入图书信息为例,此时要对图书信息表进行增删改查,则具体的编码过程分为下列 5 个步骤:
- 编写图书信息表对应的实体类
假设图书信息类名为 BookInfo,且它的各属性与图书信息表的各字段一一对应,那么要给该类添加@Entity注解,表示该类是 Room 专用的数据类型,对应的表名称也叫 BookInfo。如果 BookInfo 表的 name 字段是该表的主键,则需给 BookInfo 类的 name 属性添加@PrimaryKey与@NonNull两个注解,表示该字段是个非空的主键。下面是 BookInfo 类的定义代码例子:
(完整代码见 chapter06\src\main\java\com\example\chapter06\entity\BookInfo.java)
@Entity
public class BookInfo {
@PrimaryKey(autoGenerate = true)
@NonNull
private int id;
private String name; // 书籍名称
private String author; // 作者
private String press; // 出版社
private double price; // 价格
public int getId() {
return id;
}
public void setId(int id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
public String getAuthor() {
return author;
}
public void setAuthor(String author) {
this.author = author;
}
public String getPress() {
return press;
}
public void setPress(String press) {
this.press = press;
}
public double getPrice() {
return price;
}
public void setPrice(double price) {
this.price = price;
}
@Override
public String toString() {
return "BookInfo{" +
"id=" + id +
", name='" + name + '\'' +
", author='" + author + '\'' +
", press='" + press + '\'' +
", price=" + price +
'}';
}
}- 编写图书信息表对应的持久化类
所谓持久化,指的是将数据保存到磁盘而非内存,其实等同于增删改等 SQL 语句。假设图书信息表的持久化类名叫作 BookDao,那么该类必须添加@Dao注解,内部的记录查询方法必须添加@Query注解,记录插入方法必须添加@Insert注解,记录更新方法必须添加@Update注解,记录删除方法必须添加@Delete注解(带条件的删除方法除外)。对于记录查询方法,允许在@Query之后补充具体的查询语句以及查询条件;对于记录插入方法与记录更新方法,需明确出现重复记录时要采取哪种处理策略。下面是 BookDao 类的定义代码例子:
package com.dongnaoedu.chapter06.dao;
import androidx.room.Dao;
import androidx.room.Delete;
import androidx.room.Insert;
import androidx.room.OnConflictStrategy;
import androidx.room.Query;
import androidx.room.Update;
import com.dongnaoedu.chapter06.enity.BookInfo;
import java.util.List;
@Dao
public interface BookDao {
@Insert
void insert(BookInfo... book);
@Insert(onConflict = OnConflictStrategy.REPLACE)
void insertOneBook(BookInfo book);
@Insert
void insertBookList(List<BookInfo> bookList);
@Delete
void delete(BookInfo... book);
// 删除所有书籍信息
@Query("DELETE FROM BookInfo")
void deleteAll();
@Update
int update(BookInfo... book);
@Update(onConflict = OnConflictStrategy.REPLACE)
int updateBook(BookInfo book);
// 加载所有书籍信息
@Query("SELECT * FROM BookInfo")
List<BookInfo> queryAll();
// 根据名字加载书籍
@Query("SELECT * FROM BookInfo WHERE name = :name ORDER BY id DESC limit 1")
BookInfo queryByName(String name);
}- 编写图书信息对应的数据库类
因为先有数据库然后才有表,所以图书信息表还得放到某个数据库里,这个默认的图书数据库要从 RoomDatabase 派生而来,并添加@Database注解。下面是数据库类 BookDatabase 的定义代码例子:
(完整代码见 chapter06\src\main\java\com\example\chapter06\database\BookDatabase.java)
//entities 表示该数据库有哪些表,version 表示数据库的版本号
//exportSchema 表示是否导出数据库信息的 json 串,建议设为 false,若设为 true 还需指定 json 文件的保存路径
@Database(entities = {BookInfo.class}, version = 1, exportSchema = true)
public abstract class BookDatabase extends RoomDatabase {
// 获取该数据库中某张表的持久化对象
public abstract BookDao bookDao();
}- 在自定义的 Application 类中生命图书数据库的唯一实例
为了避免重复打开数据库造成的内存泄漏,每个数据库在 App 运行过程中理应只有一个实例,此时要求开发者自定义新的 Application 类,在该类中生命并获取图书数据库的实例,并将自定义的 Application 类设为单例模式,保证 App 运行之时有且仅有一个应用实例。下面是自定义 Application 类的代码例子:
(完整代码见 chapter06\src\main\java\com\example\chapter06\MainApplication.java)
public class MainApplication extends Application {
private static final String TAG = "MainApplication";
private static MainApplication mApp;
public HashMap<String, String> infoMap = new HashMap<>();
private BookDatabase bookDatabase;
public static MainApplication getInstance() {
return mApp;
}
@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "onCreate");
mApp = this;
bookDatabase = Room.databaseBuilder(this, BookDatabase.class, "BookInfo")
.addMigrations() // 允许迁移数据库(发生数据库变更时,Room 默认删除原数据库再创建新数据库。如此一来原来的记录会丢失,故而要改为迁移方式以便保存原有记录)
.allowMainThreadQueries() // 允许在主线程中操作数据库(Room 默认不能再主线程中操作数据库)
.build();
}
public BookDatabase getBookDB() {
return bookDatabase;
}
}- 在操作图书信息表的地方获取数据表的持久化对象
持久化对象的获取代码很简单,只需下面一行代码就够了:
// 从 App 实例中获取唯一的图书持久化对象
BookDao bookDao = MainApplication.getInstance().getBookDB().bookDao();完成以上 5 个编码步骤之后,接着调用持久化对象的 queryXXX、insertXXX、updateXXX、deleteXXX 等方法,就能实现图书信息的增删改查操作了。例程的图书信息演示页面有两个,分别是记录保存页面和记录读取页面,其中记录保存页面通过 insertOneBook 方法向数据库添加图书信息,完整代码见 chapter06\src\main\java\com\example\chapter06\RoomWriteActivity.java;而记录读取页面通过 queryAllBook 方法从数据库读取图书信息,完整代码见 chapter06\src\main\java\com\example\chapter06\RoomReadActivity.java。
运行测试 App,先打开记录保存页面,依次录入两本图书信息并保存至数据库,如图 6-21 和图 6-22 所示。再打开记录读取页面,从数据库读取图书信息并展示在页面上,如图 6-23 所示。



实战项目:购物车
购物车的应用面很广,凡是电商 App 都可以看到它的身影,之所以选择购物车作为本章的实战项目,除了它使用广泛的特点,更因为它用到了多种存储方式。现在就让我们开启电商购物车的体验之旅吧。
需求描述
电商 App 的购物车可谓是司空见惯了,以京东商城的购物车为例,一开始没有添加任何商品,此时空购物车如图 6-24 所示,而且提示去逛秒杀商场;加入几件商品之后,购物车页面如图 6-25 所示。


可见购物车除了底部有个结算行,其余部分主要是已加入购物车的商品列表,然后每个商品行左边是商品小图,右边是商品及其价格。
据此仿照本项目的购物车功能,第一次进入购物车页面,购物车里面是空的,同时提示去逛手机商场,如图 6-26 所示。接着去商场页面选购手机,随便挑了几部手机加入购物车,再返回购物车页面,即可看到购物车的商品列表,如图 6-27 所示,有商品图片、名称、数量、单价、总价等等信息。当然购物车并不仅仅只是展示待购买的商品,还要支持最终购买的结算操作、支持清空购物车等功能。


购物车的存在感很强,不仅仅再购物车页面才能看到购物车。往往在商场页面,甚至商品详情页面,都会看到某个角落冒出购物车图标。一旦有新商品加入购物车,购物车图标上的商品数量立马加一。当然,用户也能点击购物车图标直接跳到购物车页面。商场页面除了商品列表之外,页面右上角还有一个购物车图标,如图 6-28 所示,有时这个图标会在页面右下角。商品详情页面通常也有购物车图标,如图 6-29 所示,倘使用户在详情页面把商品加入购物车,那么图标上的数字也会加一。


至此大概过了一遍购物车需要实现的基本功能,提需求总是很简单的,真正落到实处还得开发者发挥想象力,把购物车做成一个功能完备的模块。
界面设计
首先找找看,购物车使用了哪些 Android 控件:
- 线性布局 LinearLayout:购物车界面从上往下排列,用到了垂直方向的线性布局。
- 网格布局 GridLayout:商场的陈列橱柜,允许分行分列展示商品。
- 相对布局 RelativeLayout:页面右上角的购物车图标,图标右上角又有数字标记,按照指定方位排列控件正是相对布局的拿手好戏。
- 其他常见控件尚有文本视图 TextView、图像视图 ImageView,按钮控件 Button 等。
然后考虑以下购物车的存储功能,到底采取了哪些存储方式:
- 数据库 SQLite:最直观的肯定是数据库了,购物车里的商品列表一定是放在 SQLite 中,增删改查都少不了它。
- 全局内存:购物车图标右上角的数字表示购物车中的商品数量,该数值建议保存在全局内存中,这样不必每次都到数据库中执行 count 操作。
- 存储卡文件:通常商品图片来自于电商平台的服务器,此时往往引入图片缓存机制,也就是首次访问先将网络图片保存到存储卡,下次访问时直接从存储卡获取缓存图片,从而提高图片的加载速度。
- 共享参数 SharedPreferences:是否首次访问网络图片,这个标志位推荐放在共享参数中,因为它需要持久化存储,并且只有一个参数信息。
真是想不到,一个小小的购物车,竟然用到了好几种存储方式。
关键代码
为了读者更好更快地完成购物车项目,下面列举几个重要功能的代码片段。
关于页面跳转
因为购物车页面允许直接跳到商场页面,并且商场页面也允许跳到购物车页面,所以如果用户在这两个页面之间来回跳转,然后再按返回键,结果发现返回的时候也是在两个页面间往返跳转。出现问题的缘由在于:每次启动活动页面都往活动栈加入一个新活动,那么返回出栈之时,也只好一个一个活动依次退出了。
解决该问题的办法参见第 4 章的“Activity 的启动模式”,对于购物车的活动跳转需要指定启动标志 FLAG_ACTIVITY_CLEAR_TOP,表示活动栈有且仅有该页面的唯一实例,如此即可避免多次返回同一页面的情况。比如从购物车页面跳到商场页面,此时活动跳转的代码示例如下:
// 从购物车页面跳到商场页面
Intent intent = new Intent(this, ShoppingChannelActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); // 设置启动标志
startActivity(intent); // 跳转到手机商场页面又如从商场页面跳到购物车页面,此时活动跳转的代码示例如下:
// 从商场页面跳到购物车页面
Intent intent = new Intent(this, ShoppingCartActivity.class);
intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
startActivity(intent); // 跳转到购物车页面关于商品图片的缓存
通常商品图片由后端服务器提供,App 打开页面时再从服务器下载所需的商品图。可是购物车模块的多个页面都会展示商品图片,如果每次都到服务器请求图片,显然既耗时间又耗流量非常不经济。因此 App 都会缓存常用的图片,一旦从服务器成功下载图片,便在手机存储卡上保存图片文件。然后下次界面需要加载商品图片时,就先从存储卡寻找该图片,如果找到就读取图片的位图信息,如果没找到就再到服务器下载图片。
以上的缓存逻辑是最简单的二级图片缓存,实际开发往往使用更高级的三级缓存机制,即“运行内存->存储卡->网络下载”。当然就初学者而言,先从掌握最简单的二级缓存开始,也就是“存储卡->网络下载”。按照二级缓存机制,可以设计以下的缓存处理逻辑:
- 先判断是否为首次访问网络图片。
- 如果是首次访问网络图片,就先从网络服务器下载图片。
- 把下载完的图片数据保存到手机的存储卡。
- 往数据库中写入商品记录,以及商品图片的本地存储路径。
- 更新共享参数中的首次访问标志。
按照上述的处理逻辑,编写的图片加载代码示例如下:
(完整代码见 chapter06\src\main\java\com\example\chapter06\ShoppingCartActivity.java)
private String mFirst = "true"; // 是否首次打开
//模拟网络数据,初始化数据库中的商品信息
private void downloadGoods() {
// 获取共享参数保存的是否首次打开参数
mFirst = SharedUtil.getInstance(this).readString("first", "true");
// 获取当前 App 的私有下载路径
String path = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + "/";
if (mFirst.equals("true")) { // 如果是首次打开
List<GoodsInfo> goodsList = GoodsInfo.getDefaultList(); // 模拟网络图片下载
for (int i = 0; i < goodsList.size(); i++) {
GoodsInfo info = goodsList.get(i);
long rowId = mGoodsHelper.insert(info); // 往商品数据库插入一条该商品的记录
info.rowId = rowId;
Bitmap pic = BitmapFactory.decodeResource(getResources(), info.pic);
String picPath = path + rowId + ".jpg";
FileUtil.saveImage(picPath, pic); // 往存储卡保存商品图片
pic.recycle(); // 回收位图对象
info.picPath = picPath;
mGoodsHelper.update(info); // 更新商品数据库中该商品记录的图片路径
}
}
// 把是否首次打开写入共享参数
SharedUtil.getInstance(this).writeString("first", "false");
}关于各页面共同的标题栏
注意到购物车、手机商场、手机详情三个页面顶部都有标题栏,而且这三个标题栏风格统一,既然如此,能否把它做成公共的标题栏呢?当然 App 界面支持局部的公共布局,以购物车的标题栏为例,公共布局的实现过程包括以下两个步骤:
步骤一,首先定义标题栏专用的布局文件,包括返回箭头、文字标题、购物车图标、商品数量表等,具体内容如下所示:
(完整代码见 chapter06\src\main\res\layout\title_shopping.xml)
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="50dp"
android:background="#aaaaff" >
<ImageView
android:id="@+id/iv_back"
android:layout_width="50dp"
android:layout_height="match_parent"
android:layout_alignParentLeft="true"
android:padding="10dp"
android:scaleType="fitCenter"
android:src="@drawable/ic_back" />
<TextView
android:id="@+id/tv_title"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:layout_centerInParent="true"
android:gravity="center"
android:textColor="@color/black"
android:textSize="20sp" />
<ImageView
android:id="@+id/iv_cart"
android:layout_width="50dp"
android:layout_height="match_parent"
android:layout_alignParentRight="true"
android:scaleType="fitCenter"
android:src="@drawable/cart" />
<TextView
android:id="@+id/tv_count"
android:layout_width="20dp"
android:layout_height="20dp"
android:layout_alignParentTop="true"
android:layout_toRightOf="@+id/iv_cart"
android:layout_marginLeft="-20dp"
android:gravity="center"
android:background="@drawable/shape_oval_red"
android:text="0"
android:textColor="@color/white"
android:textSize="15sp" />
</RelativeLayout>步骤二,然后在购物车页面的布局文件中添加如下一行 include 标签,表示引入 title_shopping.xml 的布局内容:
(完整代码见 chapter06\src\main\res\layout\activity_shopping_cart.xml)
<include layout="@layout/title_shopping" />之后重新运行测试 App,即可发现购物车页面的顶部果然出现了公共标题栏,商场页面、详情页面的公共标题栏可参考购物车页面的 include 标签。
关于商品网格的单元布局
商场页面的商品列表,长线三行二列的表格布局,每个表格单元的界面布局雷同,都是商品名称在上、商品图片居中、商品价格与添加按钮在下,看起来跟公共标题栏的处理有些类似。但后者为多个页面引用同一个标题栏,是多对一的关系;而前者为一个商场页面引用了多个商品网格,是一对多的关。因此二者的实现过程不尽相同,就商场网格而言,它的单元服用分为下列 3 个步骤:
步骤一,在商场页面的布局文件中添加 GridLayout 节点,如下所示:
(完整代码见 chapter06\src\main\res\layout\activity_shopping_channel.xml)
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/orange"
android:orientation="vertical" >
<include layout="@layout/title_shopping" />
<ScrollView
android:layout_width="match_parent"
android:layout_height="wrap_content" >
<GridLayout
android:id="@+id/gl_channel"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:columnCount="2" />
</ScrollView>
</LinearLayout>步骤二,为商场网格编写统一的商品信息布局,XML 文件内容示例如下:
(完整代码见 chapter06\src\main\res\layout\item_goods.xml)
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/ll_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:background="@color/white"
android:gravity="center"
android:orientation="vertical">
<TextView
android:id="@+id/tv_name"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/black"
android:textSize="17sp"
tools:text="小米手机" />
<ImageView
android:id="@+id/iv_thumb"
android:layout_width="180dp"
android:layout_height="150dp"
android:scaleType="fitCenter"
tools:src="@drawable/xiaomi" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="45dp"
android:orientation="horizontal">
<TextView
android:id="@+id/tv_price"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="2"
android:gravity="center"
android:textColor="@color/red"
android:textSize="15sp"
tools:text="20" />
<Button
android:id="@+id/btn_add"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_weight="3"
android:gravity="center"
android:text="加入购物车"
android:textColor="@color/black"
android:textSize="15sp" />
</LinearLayout>
</LinearLayout>步骤三,在商场的 java 代码中,先利用下面代码获取布局文件 item_goods.xml 的根视图:
View view = LayoutInFlater.from(this).inflate(R.layout.item_goods, null);再从根视图中依据控件 ID 分别取出网格单元的各控件对象:
ImageView iv_thumb = view.findViewById(R.id.iv_thumb);
TextView tv_name = view.findViewById(R.id.tv_name);
TextView tv_price = view.findViewById(R.id.tv_price);
Button btn_add = view.findViewById(R.id.btn_add);然后就能按照寻常方式操纵这些控件对象了,下面便是给网格布局加载商品的代码例子:
(完整代码见 chapter06\src\main\java\com\example\chapter06\ShoppingChannelActivity.java)
private void showGoods() {
// 商品条目是一个线性布局,设置布局的宽度为屏幕的一半
int screenWidth = getResources().getDisplayMetrics().widthPixels;
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(screenWidth / 2, LinearLayout.LayoutParams.WRAP_CONTENT);
// 查询商品数据库中的所有商品记录
List<GoodsInfo> list = mDBHelper.queryAllGoodsInfo();
// 移除下面的所有子视图
gl_channel.removeAllViews();
for (GoodsInfo info : list) {
// 获取布局文件item_goods.xml的根视图
View view = LayoutInflater.from(this).inflate(R.layout.item_goods, null);
ImageView iv_thumb = view.findViewById(R.id.iv_thumb);
TextView tv_name = view.findViewById(R.id.tv_name);
TextView tv_price = view.findViewById(R.id.tv_price);
Button btn_add = view.findViewById(R.id.btn_add);
// 给控件设置值
iv_thumb.setImageURI(Uri.parse(info.picPath));
tv_name.setText(info.name);
tv_price.setText(String.valueOf((int) info.price));
// 添加到购物车
btn_add.setOnClickListener(v -> {
addToCart(info.id, info.name);
});
// 点击商品图片,跳转到商品详情页面
iv_thumb.setOnClickListener(v -> {
Intent intent = new Intent(ShoppingChannelActivity.this, ShoppingDetailActivity.class);
intent.putExtra("goods_id", info.id);
startActivity(intent);
});
// 把商品视图添加到网格布局
gl_channel.addView(view, params);
}
}弄好了商场页面的网格单元,购物车页面的商品也可照此办理,不同之处在于购物车页面的商品行使用线性布局而非网格布局,其余实现过程依然分成上述 3 个步骤。
小结
本章主要介绍了 Android 常用的几种数据存储方式,包括共享参数 SharedPreferences 的键值对存取、数据库 SQLite 的关系型数据存取、存储卡的文件读写操作(含文本文件读写和图片文件读写)、App 全局内存的读写,以及为实现全局内存而学习的 Application 组件的生命周期及其用法。最后设计了一个实战。通过本章的学习,我们应该能够掌握以下 4 种开发技能:
- 学会使用共享参数存取键值对数据。
- 学会使用 SQLite 存取数据库记录。
- 学会使用存储卡读写文本文件和图片文件。
- 学会应用组件 Application 的用法。