第 6 章 数据存储
第 6 章 数据存储
本章介绍Android 4种存储方式的用法,包括共享参数SharedPreferences、数据库SQLite、存储卡文件、App的全局内存,另外介绍Android重要组件—应用Application的基本概念与常见用法。最后,结合本章所学的知识演示实战项目“购物车”的设计与实现。
6.1 SharedPreferences
本节介绍Android的键值对存储方式——共享参数SharedPreferences的使用方法,包括:如何将数据保存到共享参数,如何从共享参数读取数据,如何使用共享参数实现登录页面的记住密码功能,如何利用设备浏览器找到共享参数文件。
6.1.1 共享参数的用法
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 nane="age" value="30"/>
<boolean name="married" value="true" />
<float name="weight" value="100.0"/>
</map>
基于XML格式的特点,共享参数主要用于如下场合:
( 1 )简单且孤立的数据。若是复杂且相互关联的数据,则要保存于关系数据库。
( 2 )文本形式的数据。若是二进制数据,则要保存至文件。
( 3 )需要持久化存储的数据。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 = shared.edit(); // 获得编辑器的对象
editor.putString("name", "Mr Lee"); // 添加一个名为name的字符串参数
editor.putInt("age", 30); // 添加一个名为age的整型参数
editor.putBoolean("married", true); // 添加一个名为married的布尔型参数
editor.putFloat("weight", 100f); // 添加一个名为weight的浮点数参数
editor.commit(); // 交编辑器中的修改
从共享参数读取数据相对简单,直接调用共享参数实例的get * * * 方法即可读取键值,注意 get***方法的第二个参数表示默认值,读取数据的代码示例如下:
(完整代码见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所示。
![Image From 笔记-Android 开发从入门到实战](https://gitee.com/xiaweifeng/picgo/raw/master/images/202207061642827.png)
6.1.2 实现记住密码功能
上一章末尾的实战项目,登录页面下方有一个“记住密码”复选框,当时只是为了演示控件的用法,并未真正记住密码。因为用户退出后重新进入登录页面,App没有回忆起上次的登录密码。现在利用共享参数改造该项目,使之实现记住密码的功能。
改造内容主要有下列 3 处:
( 1 )声明一个共享参数对象,并在onCreate中调用getSharedPreferences方法获取共享参数的实例。
( 2 )登录成功时,如果用户勾选了“记住密码”,就使用共享参数保存手机号码与密码。也就是在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(); // 交编辑器中的修改
}
( 3 )再次打开登录页面时,App从共享参数读取手机号码与密码,并自动填入编辑框。也就是在onCreate方法中增加以下代码:
// 从share_login.xml获取共享参数对象
mShared = getSharedPreferences("share_login", 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为用户再次进入登录的界面,因为上次登录成功时已经记住密码,所以这次页面会自动填充保存的登录信息。
6.1.3 利用设备浏览器寻找共享参数文件
前面的“6.1.1 共享参数的基本用法”提到,参数文件的路径为“/data/data/应用包名/shared_prefs/xxx.xml”,然而使用手机自带的文件管理器却找不到该路径,data下面只有空目录而已。这是因为手机厂商加了层保护,不让用户查看App的核心文件,否则万一不小心误删了,App岂不是运行报错了?当然作为开发者,只要打开了手机的USB调试功能,还是有办法拿到测试应用的数据文件。首先打开Android Studio,依次选择菜单Run→Run '***',把测试应用比如chapter06安装到手机上。接着单击Android Studio左下角的logcat标签,找到已连接的手机设备和测试应用,如图6-5所示。注意到logcat窗口的右边,也就是Android Studio右下角有个竖排标签“Device File Explorer”,翻译过来叫设备文件浏览器。单击该标签按钮,此时主界面右边弹出名为“Device File Explorer”的窗口,如图6-6
在图6-6的窗口中依次展开各级目录,进到/data/data/com.example.chapter06/shared_prefs目录,在该目录下看到了参数文件share.xml。右击share.xml,并在右键菜单中选择“Save As”,把该文件保存到电脑中,之后就能查看详细的文件内容了。不仅参数文件,凡是保存在“/data/data/应用包名/”下面的所有文件,均可利用设备浏览器导出至电脑,下一节将要介绍的数据库db文件也可按照以上步骤导出。
6.2 数据库SQLite
本节介绍Android的数据库存储方式—SQLite的使用方法,包括:SQLite用到了哪些SQL语法,如何使用数据库管理器操纵SQLite,如何使用数据库帮助器简化数据库操作等,以及如何利用SQLite改进登录页面的记住密码功能。
6.2.1 SQL的基本语法
SQL本质上是一种编程语言,它的学名叫作“结构化查询语言”(全称为Structured Query Language,简称SQL)。不过SQL语言并非通用的编程语言,它专用于数据库的访问和处理,更像是一种操作命令,所以常说SQL语句而不说SQL代码。标准的SQL语句分为 3 类:数据定义、数据操纵和数据控制,但不同的数据库往往有自己的实现。SQLite是一种小巧的嵌入式数据库,使用方便、开发简单。如同MySQL、Oracle那样,SQLite也采用SQL语句管理数据,由于它属于轻型数据库,不涉及复杂的数据控制操作,因此App开发只用到数据定义和数据操纵两类SQL。此外,SQLite的SQL语法与通用的SQL语法略有不同,接下来介绍的两类SQL语 法全部基于SQLite。
1 .数据定义语言
数据定义语言全称Data Definition Language,简称DDL,它描述了怎样变更数据实体的框架结构。就SQLite而言,DDL语言主要包括 3 种操作:创建表格、删除表格、修改表结构,分别说明如下。
( 1 )创建表格
表格的创建动作由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。
( 2 )删除表格
表格的删除动作由drop命令完成,格式为“DROP TABLE IF EXISTS 表格名称;”。下面是删除用户
信息表的SQL语句例子:
DROP TABLE IF EXISTS user_info;
( 3 )修改表结构
表格的修改动作由alter命令完成,格式为“ALTER TABLE 表格名称 修改操作;”。不过SQLite 只支持增加字段 ,不支持修改字段,也不支持删除字段。对于字段增加操作,需要在alter之后补充add命令,具体格式如“ALTER TABLE 表格名称 ADD COLUMN 字段名称 字段类型;”。下面是给用户信息表增加手机号字段的SQL语句例子:
ALTER TABLE user_info ADD COLUMN phone VARCHAR;
注意,SQLite的ALTER语句每次只能添加一列字段,若要添加多列,就得分多次添加。
2 .数据操纵语言
数据操纵语言全称Data Manipulation Language,简称DML,它描述了怎样处理数据实体的内部记录。表格记录的操作类型包括添加、删除、修改、查询 4 类,分别说明如下:
( 1 )添加记录
记录的添加动作由insert命令完成,格式为“INSERT INTO 表格名称(以逗号分隔的字段名列表)VALUES (以逗号分隔的字段值列表);”。下面是往用户信息表插入一条记录的SQL语句例子:
INSERT INTO user_info (name,age,height,weight,married,update_time)
VALUES ('张三',20,170,50,0,'20200504');
( 2 )删除记录
记录的删除动作由delete命令完成,格式为“DELETE FROM 表格名称 WHERE 查询条件;”,其中查询条件的表达式形如“字段名=字段值”,多个字段的条件交集通过“AND”连接,条件并集通过“OR”连接。下面是从用户信息表删除指定记录的SQL语句例子:
DELETE FROM user_info WHERE name='张三';
( 3 )修改记录
记录的修改动作由update命令完成,格式为“UPDATE 表格名称 SET 字段名=字段值 WHERE 查询条件;”。下面是对用户信息表更新指定记录的SQL语句例子:
UPDATE user_info SET married=1 WHERE name='张三';
( 4 )查询记录
记录的查询动作由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的常见操作语句。
6.2.2 数据库管理器SQLiteDatabase
SQL语句毕竟只是SQL命令,若要在Java代码中操纵SQLite,还需专门的工具类。SQLiteDatabase便是Android提供的SQLite数据库管理器,开发者可以在活动页面代码调用openOrCreateDatabase方法获取数据库实例,参考代码如下:
(完整代码见chapter06\src\main\java\com\example\chapter06\DatabaseActivity.java)
// 创建名为test.db的数据库。数据库如果不存在就创建它,如果存在就打开它
SQLiteDatabase db = openOrCreateDatabase(getFilesDir() + "/test.db",
Context.MODE_PRIVATE, null);
String desc = String.format("数据库%s创建%s", db.getPath(), (db!=null)?"成功":"失
败");
tv_database.setText(desc);
// deleteDatabase(getFilesDir() + "/test.db"); // 删除名为test.db数据库
首次运行测试App,调用openOrCreateDatabase方法会自动创建数据库,并返回该数据库的管理器实例,创建结果如图6-7所示。
获得数据库实例之后,就能对该数据库开展各项操作了。数据库管理器SQLiteDatabase提供了若干操作数据表的API,常用的方法有 3 类,列举如下:
1 .管理类,用于数据库层面的操作
openDatabase:打开指定路径的数据库。
isOpen:判断数据库是否已打开。
close:关闭数据库。
getVersion:获取数据库的版本号。
setVersion:设置数据库的版本号。
2 .事务类,用于事务层面的操作
beginTransaction:开始事务。
setTransactionSuccessful:设置事务的成功标志。
endTransaction:结束事务。执行本方法时,系统会判断之前是否调用了
setTransactionSuccessful方法,如果之前已调用该方法就提交事务,如果没有调用该方法就回滚事务。
3 .数据处理类,用于数据表层面的操作
execSQL:执行拼接好的SQL控制语句。一般用于建表、删表、变更表结构。
delete:删除符合条件的记录。
update:更新符合条件的记录信息。
insert:插入一条记录。
query:执行查询操作,并返回结果集的游标。
rawQuery:执行拼接好的SQL查询语句,并返回结果集的游标。
在实际开发中,比较经常用到的是查询语句,建议先写好查询操作的select语句,再调用rawQuery方法
执行查询语句。
6.2.3 数据库帮助器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 类,说明如下:
1 .游标控制类方法,用于指定游标的状态
close:关闭游标。
isClosed:判断游标是否关闭。
isFirst:判断游标是否在开头。
isLast:判断游标是否在末尾。
2 .游标移动类方法,把游标移动到指定位置
moveToFirst:移动游标到开头。
moveToLast:移动游标到末尾。
moveToNext:移动游标到下一条记录。
moveToPrevious:移动游标到上一条记录。
move:往后移动游标若干条记录。
moveToPosition:移动游标到指定位置的记录。
3 .获取记录类方法,可获取记录的数量、类型以及取值
getCount:获取结果记录的数量。
getInt:获取指定字段的整型值。
getLong:获取指定字段的长整型值。
getFloat:获取指定字段的浮点数值。
getString:获取指定字段的字符串值。
getType:获取指定字段的字段类型。
鉴于数据库操作的特殊性,不方便单独演示某个功能,接下来从创建数据库开始介绍,完整演示一下数据库的读写操作。用户注册信息的演示页面包括两个,分别是记录保存页面和记录读取页面,其中记录保存页面通过insert方法向数据库添加用户信息,完整代码见 chapter06\src\main\java\com\example\chapter06\SQLiteHelperActivity.java;而记录读取页面通过query方法从数据库读取用户信息,完整代码见chapter06\src\main\java\com\example\chapter06\SQLiteHelperActivity.java。运行测试App,先打开记录保存页面,依次录入并将两个用户的注册信息保存至数据库,如图6-8和图6-9 所示。再打开记录读取页面,从数据库读取用户注册信息并展示在页面上,如图6-10所示。
上述演示页面主要用到了数据库记录的添加、查询和删除操作,对应的数据库帮助器关键代码如下所示,尤其关注里面的insert、delete、update和query方法:
(完整代码见chapter06\src\main\java\com\example\chapter06\database\UserDBHelper.java)
package com.dongnaoedu.chapter06.database;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteOpenHelper;
import android.view.WindowAnimationFrameStats;
import com.dongnaoedu.chapter06.enity.User;
import java.util.ArrayList;
import java.util.List;
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);";
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 为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.insert(TABLE_NAME, null, values);
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 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);
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;
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;
}
}
6.2.4 优化记住密码功能
在“6.1.2 实现记住密码功能”中,虽然使用共享参数实现了记住密码功能,但是该方案只能记住一个用户的登录信息,并且手机号码跟密码没有对应关系,如果换个手机号码登录,前一个用户的登录信息就被覆盖了。真正的记住密码功能应当是这样的:先输入手机号码,然后根据手机号码匹配保存的密码,一个手机号码对应一个密码,从而实现具体手机号码的密码记忆功能。现在运用数据库技术分条存储各用户的登录信息,并支持根据手机号查找登录信息,从而同时记住多个手机号的密码。具体的改造主要有下列 3 点:
( 1 )声明一个数据库的帮助器对象,然后在活动页面的onResume方法中打开数据库连接,在onPasue方法中关闭数据库连接,示例代码如下:
(完整代码见chapter06\src\main\java\com\example\chapter06\LoginSQLiteActivity.java)
private UserDBHelper mHelper; // 声明一个用户数据库的帮助器对象
@Override
protected void onResume() {
super.onResume();
mHelper = UserDBHelper.getInstance(this, 1); // 获得用户数据库帮助器的实例
mHelper.openWriteLink(); // 恢复页面,则打开数据库连接
}
@Override
protected void onPause() {
super.onPause();
mHelper.closeLink(); // 暂停页面,则关闭数据库连接
}
( 2 )登录成功时,如果用户勾选了“记住密码”,就将手机号码及其密码保存至数据库。也就是在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); // 往用户数据库添加登录成功的用户信息
}
( 3 )再次打开登录页面,用户输入手机号再点击密码框的时候,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 章的“5.3.2 焦点变更监听器”。就本案例而言,光标切到密码框触发焦点变更事件,具体处理逻辑要求重写监听器的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所示。由效果图可见,这次实现了真正意义上的记住密码功能。
6.3 存储卡的文件操作
本节介绍Android的文件存储方式—在存储卡上读写文件,包括:公有存储空间与私有存储空间有什么区别、如何利用存储卡读写文本文件、如何利用存储卡读写图片文件等。
6.3.1 私有存储空间与公共存储空间
为了更规范地管理手机存储空间,Android从7.0开始将存储卡划分为私有存储和公共存储两大部分,也就是分区存储方式,系统给每个App都分配了默认的私有存储空间。App在私有空间上读写文件无须任何授权,但是若想在公共空间读写文件,则要在AndroidManifest.xml里面添加下述的权限配置。
<!-- 存储卡读写 -->
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAG" />
但是即使App声明了完整的存储卡操作权限,系统仍然默认禁止该App访问公共空间。打开手机的系统设置界面,进入到具体应用的管理页面,会发现该应用的存储访问权限被禁止了。当然禁止访问只是不让访问存储卡的公共空间,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).t
oString();
// 获取当前App的私有存储路径
String privatePath =
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString();
boolean isLegacy = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
// Android10的存储空间默认采取分区方式,此处判断是传统方式还是分区方式
isLegacy = Environment.isExternalStorageLegacy();
}
String desc = "系统的公共存储路径位于" + publicPath +
"\n\n当前App的私有存储路径位于" + privatePath +
"\n\nAndroid7.0之后默认禁止访问公共存储目录" +
"\n\n当前App的存储空间采取" + (isLegacy?"传统方式":"分区方式");
tv_path.setText(desc);
该例子运行之后获得的路径信息如图6-14所示,可见应用的私有空间路径位于“存储卡根目录/Android/data/应用包名/files/Download”这个目录中。
6.3.2 在存储卡上读写文本文件
文本文件的读写借助于文件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 (FileInputStream fis = new FileInputStream(path)) {
byte[] b = new byte[fis.available()];
fis.read(b); // 从文件输入流读取字节数组
readStr = new String(b); // 把字节数组转换为字符串
} catch (Exception e) {
e.printStackTrace();
}
return readStr; // 返回文本文件中的文本字符串
}
方式二:使用字符流存储读取文件
// 把字符串保存到指定路径的文本文件
public static void saveText(String path, String txt) {
BufferedWriter os = null;
try {
os = new BufferedWriter(new FileWriter(path));
os.write(txt);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (os != null) {
try {
os.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
// 从指定路径的文本文件中读取内容字符串
public static String openText(String path) {
BufferedReader is = null;
StringBuilder sb = new StringBuilder();
try {
is = new BufferedReader(new FileReader(path));
String line = null;
while ((line = is.readLine()) != null) {
sb.append(line);
}
} catch (Exception e) {
e.printStackTrace();
} finally {
if (is != null) {
try {
is.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
return sb.toString();
}
接着分别创建写文件页面和读文件页面,其中写文件页面调用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所示。
6.3.3 在存储卡上读写图片文件
文本文件读写可以转换为对字符串的读写,而图片文件保存的是图像数据,需要专门的位图工具Bitmap处理。位图对象依据来源不同又分成 3 种获取方式,分别对应位图工厂BitmapFactory的下列 3 种方法:
- decodeResource:从指定的资源文件中获取位图数据。例如下面代码表示从资源文件huawei.png获取位图对象:
Bitmap bitmap = BitmapFactory.decodeResource(getResources(),R.drawable.huawei);
decodeFile:从指定路径的文件中获取位图数据。注意从Android 10开始,该方法只适用于私有目录下的图片,不适用公共空间下的图片。
decodeStream:从指定的输入流中获取位图数据。比如使用IO流打开图片文件,此时文件输入流
// 从指定路径的图片文件中读取位图数据 public static Bitmap openImage(String path) { Bitmap bitmap = null; FileInputStream fis = null; try { fis = new FileInputStream(path); bitmap = BitmapFactory.decodeStream(fis); } catch (Exception e) { e.printStackTrace(); } finally { if (fis != null) { try { fis.close(); } catch (IOException e) { e.printStackTrace(); } } } return bitmap; }
对象即可作为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();
}
return bitmap; // 返回图片文件中的位图数据
}
得到位图对象之后,就能在图像视图上显示位图。图像视图ImageView提供了下列方法显示各种来源的图片:
setImageResource:设置图像视图的图片资源,该方法的入参为资源图片的编号,形如
“R.drawable.去掉扩展名的图片名称”。
setImageBitmap:设置图像视图的位图对象,该方法的入参为Bitmap类型。
setImageURI:设置图像视图的路径对象,该方法的入参为Uri类型。字符串格式的文件路径可通过代码“Uri.parse(file_path)”转换成路径对象。读取图片文件的花样倒是挺多,把位图数据写入图片文件却只有一种,即通过位图对象的compress方法将位图数据压缩到文件输出流。具体的图片写入代码如下所示:
// 把位图数据保存到指定路径的图片文件
public static void saveImage(String path, Bitmap bitmap) {
FileOutputStream fos = null;
try {
fos = new FileOutputStream(path);
// 把位图数据压缩到文件输出流中
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, fos);
} catch (Exception e) {
e.printStackTrace();
} finally {
if (fos != null) {
try {
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
接下来完整演示一遍图片文件的读写操作,首先创建图片写入页面,从某个资源图片读取位图数据,再把位图数据保存为私有目录的图片文件,相关代码示例如下:
(完整代码见chapter06\src\main\java\com\example\chapter06\ImageWriteActivity.java)
// 获取当前App的私有下载目录
String path = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() +
"/";
// 从指定的资源文件中获取位图对象
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.huawei);
String file_path = path + DateUtil.getNowDateTime("") + ".jpeg";
FileUtil.saveImage(file_path, bitmap); // 把位图对象保存为图片文件
tv_path.setText("图片文件的保存路径为:\n" + file_path);
然后创建图片读取页面,从私有目录找到图片文件,并挑出一张在图像视图上显示,相关代码示例如下:
(完整代码见chapter06\src\main\java\com\example\chapter06\ImageReadActivity.java)
// 获取当前App的私有下载目录
mPath = 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所示。
6.4 应用组件Application
本节介绍Android重要组件Application的基本概念和常见用法。首先说明Application的生命周期贯穿了App的整个运行过程,接着利用Application实现App全局变量的读写,然后阐述了如何借助App实例来操作Room数据库框架。
6.4.1 Application的生命周期
Application是Android的一大组件,在App运行过程中有且仅有一个Application对象贯穿应用的整个生命周期。打开AndroidManifest.xml,发现activity节点的上级正是application节点,不过该节点并未指定name属性,此时App采用默认的Application实例。注意到每个activity节点都指定了name属性,譬如常见的name属性值为.MainActivity,让人知晓该activity的入口代码是MainActivity.java。现在尝试给application节点加上name属性,看看其庐山真面目,具体步骤说明如下:
( 1 )打开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">
( 2 )在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");
}
}
( 3 )运行测试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方法的回调了。
6.4.2 Application操作全局变量
C/C++有全局变量的概念,因为全局变量保存在内存中,所以操作全局变量就是操作内存,显然内存的读写速度远比读写数据库或读写文件快得多。所谓全局,指的是其他代码都可以引用该变量,因此全局变量是共享数据和消息传递的好帮手。不过Java没有全局变量的概念,与之比较接近的是类里面的静态成员变量,该变量不但能被外部直接引用,而且它在不同地方引用的值是一样的(前提是在引用期间不能改动变量值),所以借助静态成员变量也能实现类似全局变量的功能。根据上一小节的介绍可知,Application的生命周期覆盖了App运行的全过程。不像短暂的Activity生命周期,一旦退出该页面,Activity实例就被销毁。因此,利用Application的全生命特性,能够在Application实例中保存全局变量。
适合在Application中保存的全局变量主要有下面 3 类数据:
( 1 )会频繁读取的信息,例如用户名、手机号码等。
( 2 )不方便由意图传递的数据,例如位图对象、非字符串类型的集合对象等。
( 3 )容易因频繁分配内存而导致内存泄漏的对象,例如Handler处理器实例等。
要想通过Application实现全局内存的读写,得完成以下 3 项工作:
( 1 )编写一个继承自Application的新类MainApplication。该类采用单例模式,内部先声明自身类的一个静态成员对象,在创建App时把自身赋值给这个静态对象,然后提供该对象的获取方法getInstance。
具体实现代码示例如下:
(完整代码见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; // 在打开应用时对静态的应用实例赋值
}
}
( 2 )在活动页面代码中调用MainApplication的getInstance方法,获得它的一个静态对象,再通过该对象访问MainApplication的公共变量和公共方法。
( 3 )不要忘了在AndroidManifest.xml中注册新定义的Application类名,也就是给application节点增加android:name属性,其值为.MainApplication。接下来演示如何读写内存中的全局变量,首先分别创建写内存页面和读内存页面,其中写内存页面把用户的注册信息保存到全局变量infoMap,完整代码见 chapter06\src\main\java\com\example\chapter06\AppWriteActivity.java;
String name = et_name.getText().toString();
String age = et_age.getText().toString();
String height = et_height.getText().toString();
String weight = et_weight.getText().toString();
app = MyApplication.getInstance();
app.infoMap.put("name", name);
app.infoMap.put("age", age);
app.infoMap.put("height", height);
app.infoMap.put("weight", weight);
app.infoMap.put("married", ck_married.isChecked() ? "是" : "否");
而读内存页面从全局变量infoMap读取用户的注册信息,完整代码见chapter06\src\main\java\com\example\chapter06\AppReadActivity.java。
private void reload() {
String name = app.infoMap.get("name");
if (name == null) {
return;
}
String age = app.infoMap.get("age");
String height = app.infoMap.get("height");
String weight = app.infoMap.get("weight");
String married = app.infoMap.get("married");
et_name.setText(name);
et_age.setText(age);
et_height.setText(height);
et_weight.setText(weight);
if ("是".equals(married)) {
ck_married.setChecked(true);
} else {
ck_married.setChecked(false);
}
}
然后运行测试App,先打开内存写入页面,录入注册信息后保存至全局变量,此时写入界面如图6-19所示。再打开内存读取页面,App自动从全局变量获取注册信息,并展示拼接后的信息文本,此时读取界面如图6-20所示。
6.4.3 利用Room简化数据库操作
虽然Android提供了数据库帮助器,但是开发者在进行数据库编程时仍有诸多不便,比如每次增加一张新表,开发者都得手工实现以下代码逻辑:
( 1 )重写数据库帮助器的onCreate方法,添加该表的建表语句。
( 2 )在插入记录之时,必须将数据实例的属性值逐一赋给该表的各字段。
( 3 )在查询记录之时,必须遍历结果集游标,把各字段值逐一赋给数据实例。
( 4 )每次读写操作之前,都要先开启数据库连接;读写操作之后,又要关闭数据库连接。
上述的处理操作无疑存在不少重复劳动,数年来引得开发者叫苦连连。为此各类数据库处理框架纷纷涌现,包括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 个步骤:
1 .编写图书信息表对应的实体类
假设图书信息类名为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)
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 +
'}';
}
}
2 .编写图书信息表对应的持久化类
所谓持久化,指的是将数据保存到磁盘而非内存,其实等同于增删改等SQL语句。假设图书信息表的持久化类名叫作BookDao,那么该类必须添加“@Dao”注解,内部的记录查询方法必须添加“@Query”注解,记录插入方法必须添加“@Insert”注解,记录更新方法必须添加“@Update”注解,记录删除方法必须添加“@Delete”注解(带条件的删除方法除外)。对于记录查询方法,允许在@Query之后补充具体的查询语句以及查询条件;对于记录插入方法与记录更新方法,需明确出现重复记录时要采取哪种处理策略。下面是BookDao类的定义代码例子:
(完整代码见chapter06\src\main\java\com\example\chapter06\dao\BookDao.java)
@Dao
public interface BookDao {
@Insert
void insert(BookInfo... book);
@Delete
void delete(BookInfo... book);
// 删除所有书籍信息
@Query("DELETE FROM BookInfo")
void deleteAll();
@Update
int update(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);
}
3 .编写图书信息表对应的数据库类
因为先有数据库然后才有表,所以图书信息表还得放到某个数据库里,这个默认的图书数据库要从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();
}
4 .在自定义的Application类中声明图书数据库的唯一实例
为了避免重复打开数据库造成的内存泄漏问题,每个数据库在App运行过程中理应只有一个实例,此时要求开发者自定义新的Application类,在该类中声明并获取图书数据库的实例,并将自定义的Application类设为单例模式,保证App运行之时有且仅有一个应用实例。下面是自定义Application类的代码例子:
(完整代码见chapter06\src\main\java\com\example\chapter06\MainApplication.java)
public class MainApplication extends Application {
private final static String TAG = "MainApplication";
private static MainApplication mApp; // 声明一个当前应用的静态实例
private BookDatabase bookDatabase; // 声明一个书籍数据库对象
// 利用单例模式获取当前应用的唯一实例
public static MainApplication getInstance() {
return mApp;
}
@Override
public void onCreate() {
super.onCreate();
Log.d(TAG, "onCreate");
mApp = this; // 在打开应用时对静态的应用实例赋值
// 构建书籍数据库的实例
bookDatabase = Room.databaseBuilder(mApp, BookDatabase.class,"book")
.addMigrations() // 允许迁移数据库(发生数据库变更时,Room默认删除原数据库再创建新数据库。如此一来原来的记录会丢失,故而要改为迁移方式以便保存原有记录)
.allowMainThreadQueries() // 允许在主线程中操作数据库(Room默认不能在主线程中操作数据库)
.build();
}
// 获取书籍数据库的实例
public BookDatabase getBookDB(){
return bookDatabase;
}
}
5 .在操作图书信息表的地方获取数据表的持久化对象
持久化对象的获取代码很简单,只需下面一行代码就够了:
// 从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所示。
6.5 实战项目:购物车
购物车的应用面很广,凡是电商App都可以看到它的身影,之所以选择购物车作为本章的实战项目,除了它使用广泛的特点,更因为它用到了多种存储方式。现在就让我们开启电商购物车的体验之旅吧。
6.5.1 需求描述
电商App的购物车可谓是司空见惯了,以京东商城的购物车为例,一开始没有添加任何商品,此时空购物车如图6-24所示,而且提示去逛秒杀商场;加入几件商品之后,购物车页面如图6-25所示。
图6-24 京东App购物车的初始页面
图6-25 京东App购物车加了几件商品
可见购物车除了底部有个结算行,其余部分主要是已加入购物车的商品列表,然后每个商品行左边是商品小图,右边是商品名称及其价格。据此仿照本项目的购物车功能,第一次进入购物车页面,购物车里面是空的,同时提示去逛手机商场, 如图6-26所示。接着去商场页面选购手机,随便挑了几部手机加入购物车,再返回购物车页面,即可看到购物车的商品列表,如图6-27所示,有商品图片、名称、数量、单价、总价等等信息。当然购物车并不仅仅只是展示待购买的商品,还要支持最终购买的结算操作、支持清空购物车等功能。
图6-26 首次打开购物车页面
图6-27 选购商品后的购物车
购物车的存在感很强,不仅仅在购物车页面才能看到购物车。往往在商场页面,甚至商品详情页面,都会看到某个角落冒出购物车图标。一旦有新商品加入购物车,购物车图标上的商品数量立马加一。当然,用户也能点击购物车图标直接跳到购物车页面。商场页面除了商品列表之外,页面右上角还有一个购物车图标,如图6-28所示,有时这个图标会在页面右下角。商品详情页面通常也有购物车图标,如图6-29所示,倘使用户在详情页面把商品加入购物车,那么图标上的数字也会加一。
图6-29 手机详情页面
至此大概过了一遍购物车需要实现的基本功能,提需求总是很简单的,真正落到实处还得开发者发挥想象力,把购物车做成一个功能完备的模块。
6.5.2 界面设计
首先找找看,购物车使用了哪些Android控件:
线性布局LinearLayout:购物车界面从上往下排列,用到了垂直方向的线性布局。
网格布局GridLayout:商场页面的陈列橱柜,允许分行分列展示商品。
相对布局RelativeLayout:页面右上角的购物车图标,图标右上角又有数字标记,按照指定方位排列控件正是相对布局的拿手好戏。
其他常见控件尚有文本视图TextView、图像视图ImageView,按钮控件Button等。
然后考虑一下购物车的存储功能,到底采取了哪些存储方式:
数据库SQLite:最直观的肯定是数据库了,购物车里的商品列表一定是放在SQLite中,增删改查都少不了它。
全局内存:购物车图标右上角的数字表示购物车中的商品数量,该数值建议保存在全局内存中,这样不必每次都到数据库中执行count操作。
存储卡文件:通常商品图片来自于电商平台的服务器,此时往往引入图片缓存机制,也就是首次访问先将网络图片保存到存储卡,下次访问时直接从存储卡获取缓存图片,从而提高图片的加载速度。
共享参数SharedPreferences:是否首次访问网络图片,这个标志位推荐放在共享参数中,因为它需要持久化存储,并且只有一个参数信息。真是想不到,一个小小的购物车,竟然用到了好几种存储方式。
6.5.3 关键代码
为了读者更好更快地完成购物车项目,下面列举几个重要功能的代码片段。
1 .关于页面跳转
因为购物车页面允许直接跳到商场页面,并且商场页面也允许跳到购物车页面,所以如果用户在这两个页面之间来回跳转,然后再按返回键,结果发现返回的时候也是在两个页面间往返跳转。出现问题的缘由在于:每次启动活动页面都往活动栈加入一个新活动,那么返回出栈之时,也只好一个一个活动依次退出了。解决该问题的办法参见第 4 章的“4.1.3 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); // 跳转到购物车页面
2 .关于商品图片的缓存
通常商品图片由后端服务器提供,App打开页面时再从服务器下载所需的商品图。可是购物车模块的多个页面都会展示商品图片,如果每次都到服务器请求图片,显然既耗时间又耗流量非常不经济。因此App都会缓存常用的图片,一旦从服务器成功下载图片,便在手机存储卡上保存图片文件。然后下次界面需要加载商品图片时,就先从存储卡寻找该图片,如果找到就读取图片的位图信息,如果没找到就再到服务器下载图片。
以上的缓存逻辑是最简单的二级图片缓存,实际开发往往使用更高级的三级缓存机制,即“运行内存→存储卡→网络下载”。当然就初学者而言,先从掌握最简单的二级缓存开始,也就是“存储卡→网络下载”。
按照二级缓存机制,可以设计以下的缓存处理逻辑:
( 1 )先判断是否为首次访问网络图片。
( 2 )如果是首次访问网络图片,就先从网络服务器下载图片。
( 3 )把下载完的图片数据保存到手机的存储卡。
( 4 )往数据库中写入商品记录,以及商品图片的本地存储路径。
( 5 )更新共享参数中的首次访问标志。
按照上述的处理逻辑,编写的图片加载代码示例如下:
(完整代码见chapter06\src\main\java\com\example\chapter06\ShoppingCartActivity.java)
private String mFirst = "true"; // 是否首次打开
// 模拟网络数据,初始化数据库中的商品信息
private void downloadGoods() {
// 获取共享参数保存的是否首次打开参数
mFirst = SharedUtil.getIntance(this).readString("first", "true");
// 获取当前App的私有下载路径
String path =
getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + "/";
if (mFirst.equals("true")) { // 如果是首次打开
ArrayList<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 pic_path = path + rowid + ".jpg";
FileUtil.saveImage(pic_path, pic); // 往存储卡保存商品图片
pic.recycle(); // 回收位图对象
info.pic_path = pic_path;
mGoodsHelper.update(info); // 更新商品数据库中该商品记录的图片路径
}
}
// 把是否首次打开写入共享参数
SharedUtil.getIntance(this).writeString("first", "false");
}
3 .关于各页面共同的标题栏
注意到购物车、手机商场、手机详情三个页面顶部都有标题栏,而且这三个标题栏风格统一,既然如此,能否把它做成公共的标题栏呢?当然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标签。
4 .关于商品网格的单元布局
商场页面的商品列表,呈现三行二列的表格布局,每个表格单元的界面布局雷同,都是商品名称在上、商品图片居中、商品价格与添加按钮在下,看起来跟公共标题栏的处理有些类似。但后者为多个页面引用同一个标题栏,是多对一的关系;而前者为一个商场页面引用了多个商品网格,是一对多的关系。因此二者的实现过程不尽相同,就商场网格而言,它的单元复用分为下列 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"
android:id="@+id/ll_item"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:gravity="center"
android:background="@color/white"
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" />
<ImageView
android:id="@+id/iv_thumb"
android:layout_width="180dp"
android:layout_height="150dp"
android:scaleType="fitCenter" />
<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" />
<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 = Utils.getScreenWidth(this);
LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(
screenWidth/2, LinearLayout.LayoutParams.WRAP_CONTENT);
gl_channel.removeAllViews(); // 移除下面的所有子视图
// 查询商品数据库中的所有商品记录
List<GoodsInfo> goodsArray = mGoodsHelper.query("1=1");
for (final GoodsInfo info : goodsArray) {
// 获取布局文件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);
tv_name.setText(info.name); // 设置商品名称
iv_thumb.setImageURI(Uri.parse(info.pic_path)); // 设置商品图片
iv_thumb.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
Intent intent = new Intent(ShoppingChannelActivity.this,
ShoppingDetailActivity.class);
intent.putExtra("goods_id", info.rowid);
startActivity(intent); // 跳到商品详情页面
}
});
tv_price.setText("" + (int)info.price); // 设置商品价格
btn_add.setOnClickListener(new View.OnClickListener() {
@Override
public void onClick(View v) {
addToCart(info.rowid, info.name); // 添加到购物车
}
});
gl_channel.addView(view, params); // 把商品视图添加到网格布局
}
}
弄好了商场页面的网格单元,购物车页面的商品行也可照此办理,不同之处在于购物车页面的商品行使用线性布局而非网格布局,其余实现过程依然分成上述 3 个步骤。
6.6 小结
本章主要介绍了Android常用的几种数据存储方式,包括共享参数SharedPreferences的键值对存取、数据库SQLite的关系型数据存取、存储卡的文件读写操作(含文本文件读写和图片文件读写)、App全局内存的读写,以及为实现全局内存而学习的Application组件的生命周期及其用法。最后设计了一个实战项目“购物车”,通过该项目的编码进一步复习巩固本章几种存储方式的使用。
通过本章的学习,我们应该能够掌握以下 4 种开发技能:
( 1 )学会使用共享参数存取键值对数据。
( 2 )学会使用SQLite存取数据库记录。
( 3 )学会使用存储卡读写文本文件和图片文件。
( 4 )学会应用组件Application的用法。
6.7 课后练习题
一、填空题
1 .SharedPreferences采用的存储结构是 __ 的键值对方式。
2 .Android可以直接操作的数据库名为 __ 。
3 . __ 是Android提供的SQLite数据库管理器。
4 .数据库记录的修改动作由 __ 命令完成。
5 .为了确保在App运行期间只有唯一的Application实例,可以采取 __ __模式实现。
二、判断题(正确打√,错误打×)
1 .共享参数只能保存字符串类型的数据。( )
2 .SQLite可以直接读写布尔类型的数据。( )
3 .从Android 7.0开始,系统默认禁止App访问公共存储空间。( )
4 .App在私有空间上读写文件无须任何授权。( )
5 .App终止时会调用Application的onTerminate方法。( )
三、选择题
1 .( )不是持久化的存储方式。
A.共享参数
B.数据库
C.文件
D.全局变量
2 .DDL语言包含哪些数据库操作( )。
A.创建表格
B.删除表格
C.清空表格
D.修改表结构
3 .调用( )方法会返回结果集的Cursor对象。
A.update
B.insert
C.query
D.rawQuery
4 .位图工厂BitmapFactory的( )方法支持获取图像数据。
A.decodeStream
B.decodeFile
C.decodeImage
D.decodeResource
5 .已知某个图片文件的存储卡路径,可以调用( )方法将它显示到图像视图上。
A.setImageBitmap
B.setImageFile
C.setImageURI
D.setImageResource
四、简答题
请简要描述共享参数与数据库两种存储方式的主要区别。
五、动手练习
1 .请上机实验完善找回密码项目的记住密码功能,分别采用以下两种存储方式:
( 1 )使用共享参数记住上次登录成功时输入的用户名和密码。
( 2 )使用SQLite数据库记住用户名对应的密码,也就是根据用户名自动填写密码。
2 .请上机实验本章的购物车项目,要求实现下列功能:
( 1 )往购物车添加商品。
( 2 )自动计算购物车中所有商品的总金额。
( 3 )移除购物车里的某个商品。
( 4 )清空购物车。