键值对
此小节介绍Android的键值对存储方式的使用方法,其中包括:如何将数据保存到共享参数,如何从共享参数读取数据,如何使用共享参数实现登陆页面的记住密码功能,如何使用Jetpack集成的数据仓库。
共享参数的用法
SharedPreferences
是Android的一个轻量级存储工具,它采用的存储结构是Key-Value的键值对方式,类似于Java的Properties,二者都是把Key-Value的键值对保存在配置文件中。不同的是,Properties的文件内容形如Key=Value,而SharedPreferences
的存储介质是MXL文件,且以XML标记保存键值对。保存共享参数键值对信息的文件为:/data/data/应用包名/shared_prefs/文件名.xml。下面是一个共享参数的XML文件例子:
<?xml version='1.0' encoding='utf-8' standalone='yes' ?>
<map>
<string name="update_time">2024-05-03 14:13:51</string>
<string name="name">哈哈</string>
<float name="weight" value="60.0" />
<boolean name="married" value="true" />
<int name="age" value="18" />
<long name="height" value="170" />
</map>
基于XML格式的特点,共享参数主要用于如下场合:
- 简单且孤立的数据。若是复杂且相互关联的数据,则要保存在关系数据库中。
- 文本形式的数据。若是二进制的数据,则要保存至文件。
- 需要持久化存储的数据。App退出后再次启动时,之前保存的数据仍然有效。
在实际开发中,共享参数经常存储的数据包括:App的个性化配置信息、用户使用App的行为信息、临时需要保存的片段信息等。
共享参数对数据的存储和读取操作类似于Map,也有存储数据的put
方法,以及读取数据的get
方法。调用getSharedPreferences
方法可以获得共享参数实例,获取代码示例如下:
// 从share.xml中获取共享参数对象
SharedPreferences share = getSharedPreferences("share", MODE_PRIVATE);
由以上代码可知,getSharedPreferences
第一个参数是文件名,填share表示共享参数的文件名是share.xml;第二个参数是操作模式,填MODE_PRIVATE
表示私有模式。
往共享参数存储数据要借助于Editor
类,保存数据的代码示例如下:
SharedPreferences.Editor editor = mShared.edit(); // 获得编辑器的对象
editor.putString("name", name); // 添加一个名叫name的字符串参数
editor.putInt("age", Integer.parseInt(age)); // 添加一个名叫age的整型参数
editor.putLong("height", Long.parseLong(height)); // 添加一个名叫height的长整型参数
editor.putFloat("weight", Float.parseFloat(weight)); // 添加一个名叫weight的浮点数参数
editor.putBoolean("married", isMarried); // 添加一个名叫married的布尔型参数
editor.commit(); // 提交编辑器中的修改
注意上述代码采用commit
方法提交,该方法会把数据直接写入磁盘。如果想把更好的性能,可将commit
方法改为apply
,该方法的提交操作会先将数据写入内存,然后异步把数据写入磁盘。
从共享参数读取数据相对简单,直接调用共享参数实例的get***
方法即可读取键值,注意get***
方法的第二个参数表示默认值。读取数据的代码示例如下:
String name = shared.getString("name", ""); // 从共享数据获取文件名为name的字符串
int age = shared.getString("age", 0); // 从共享数据获取文件名为age的整型数
boolean married = shared.getBoolean("married", false); // 从共享数据获取文件名为married的布尔值
float weight = shared.getFloat("weight", 0.0f); // 从共享数据获取文件名为weight的浮点数
下面演示共享参数的存取过程:现在编辑页面录入用户注册信息,点击保存按钮把数据提交至共享数据参数,如下图:
再到查看页面浏览用户注册信息,App从共享参数中读取各项数据,并将注册信息显示在页面上,如下图:
实现记住密码功能
上一小节项目中,登录界面下方有一个“记住密码”复选框,当时只是为了演示控件的用法,并未实现真正记住密码的功能。现在利用共享参数对该项目改造一下,使之实现记住密码功能。
改造点主要与下列3处:
- 声明一个共享参数对象,并在
onCreate
方法中调用getSharedPreferences
方法获取共享参数的实例。 - 登录成功时,如果用户勾选了“记住密码”复选框,就使用共享参数保存手机号码与密码。也就是在
loginSuccess
方法中添加以下代码:
// 如果勾选了”记住密码“,就把手机号码和密码都保存到共享参数中
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("share_login", MODE_PRIVATE);
// 获取共享参数保存的手机号码
String phone = mShared.getString("phone", "");
// 获取共享参数保存的密码
String password = mShared.getString("password", "");
et_phone.setText(phone); // 往手机号码编辑框填写上次保存的手机号
et_password.setText(password); // 往密码编辑框填写上次保存的密码
代码修改完毕运行App,只要用户上次登录成功时勾选了“记住密码”复选框,下次进入登陆页面后App就会自动填上手机号码和密码。效果如下图:
更安全的数据仓库
虽然SharedPreferences
用起来比较方便,但是在一些特殊场景会产生问题。比如共享参数保存的数据较多时,初始化共享参数会把整个文件加载进内存,加载耗时可能导致主线程堵塞。又如在调用apply
方法保存数据时,频繁apply
容易导致线程等待超时。此时Android官方推出了数据仓库Datastore
,并将其作为Jetpack
库的基础组件。Datastore
提供了两种实现方式,分别是Preferences DataStore
和Proto DataStore
,前者采用键值对存储数据,后者采用自定义类型存储数据。其中Preferences DataStore
可以直接代替SharedPreferences
。
由于DataStore
并未集成到SDK中,而是作为第三方框架提供,因此首先要修改模块的build.gradle文件,往dependencies节点添加下面两行配置,表示导入指定版本的DataStore库:
implementation("androidx.datastore:datastore-preferences:1.1.1")
implementation("androidx.datastore:datastore-rxjava3:1.1.1")
数据仓库的用法类似于共享参数,首先要指定仓库名称,并创建仓库实例,代码示例如下:
private static DatastoreUtil instance; // 声明一个数据仓库工具的实例
private RxDataStore<Preferences> mDataStore; // 声明一个数据仓库实例
private DatastoreUtil(Context context) {
mDataStore = new RxPreferenceDataStoreBuilder(context.getApplicationContext(), "datastore").build();
}
// 获取数据仓库工具的实例
public static DatastoreUtil getInstance(Context context) {
if (instance == null) {
instance = new DatastoreUtil(context);
}
return instance;
}
其次从仓库实例中获取指定键名的数据,下面的代码模板演示了如何从数据仓库中读取字符串值:
// 获取指定名称的字符串值
public String getStringValue(String key) {
Preferences.Key<String> keyId = PreferencesKeys.stringKey(key);
Flowable<String> flow = mDataStore.data().map(prefs -> prefs.get(keyId));
try {
return flow.blockingFirst();
} catch (Exception e) {
return "";
}
}
最后往仓库实例写入指定键值,下面的代码模板演示了如何将字符串值写入仓库:
// 设置指定名称的字符串值
public void setStringValue(String key, String value) {
Preferences.Key<String> keyId = PreferencesKeys.stringKey(key);
Single<Preferences> result = mDataStore.updateDataAsync(prefs -> {
MutablePreferences mutablePrefs = prefs.toMutablePreferences();
//String oldValue = prefs.get(keyId);
mutablePrefs.set(keyId, value);
return Single.just(mutablePrefs);
});
}
前面把数据仓库的初始化以及读写操作封装在DatastoreUtil中,接下来通过该工具类即可方便地访问数据仓库了。往数据仓库保存数据地代码示例如下:
DatastoreUtil datastore = DatastoreUtil.getInstance(this); // 获取数据仓库工具的实例
datastore.setStringValue("name", name); // 添加一个名叫name的字符串
datastore.setIntValue("age", Integer.parseInt(age)); // 添加一个名叫age的整数
datastore.setIntValue("height", Integer.parseInt(height)); // 添加一个名叫height的整数
datastore.setDoubleValue("weight", Double.parseDouble(weight)); // 添加一个名叫weight的双精度数
datastore.setBooleanValue("married", isMarried); // 添加一个名叫married的布尔值
datastore.setStringValue("update_time", DateUtil.getNowDateTime("yyyy-MM-dd HH:mm:ss"));
ToastUtil.show(this, "数据已写入数据仓库");
从仓库获取数据地代码示例如下:
// 从数据仓库中读取信息
private void readDatastore() {
DatastoreUtil datastore = DatastoreUtil.getInstance(this); // 获取数据仓库工具的实例
String desc = "数据仓库中保存的信息如下:";
desc = String.format("%s\n %s为%s", desc, "姓名",
datastore.getStringValue("name"));
desc = String.format("%s\n %s为%d", desc, "年龄",
datastore.getIntValue("age"));
desc = String.format("%s\n %s为%d", desc, "身高",
datastore.getIntValue("height"));
desc = String.format("%s\n %s为%.2f", desc, "体重",
datastore.getDoubleValue("weight"));
desc = String.format("%s\n %s为%b", desc, "婚否",
datastore.getBooleanValue("married"));
desc = String.format("%s\n %s为%s", desc, "更新时间",
datastore.getStringValue("update_time"));
tv_data.setText(desc);
}
运行App,打开记录保存页面,填写数据后点击“保存到数据仓库”按钮。然后再打开记录获取界面即可看到保存数据。
数据库
此小节介绍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 表格名称 修改操作;
。不过SQLite只支持增加字段,不支持修改字段,也不支持删除字段。对于字段增加操作,需要在alter
之后补充add
命令,具体格式如ALTER TABLE 表格名称 ADD COLUMN 字段名称 字段类型;
。下面是给用户信息表增加手机号字段的SQL语句例子:
ALTER TABLE user_info ADD COLUMN phone VARCHAR
注意: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;
数据库管理器SQLiteDatabase
SQL语句毕竟只是SQL命令,若要在Java代码中操纵SQLite,还需要专门的工具类。SQLiteDatabase
便是Android提供的SQLite数据库管理器,开发者可以在活动页面代码中调用openOrCreateDatabase
方法获取数据库实例,参考代码如下:
// 创建或打开数据库。数据库如果不存在就创建它,如果存在就打开它
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"); // 删除数据库
运行App,调用openOrCreateDatabase
方法会自动创建数据库,并返回该数据库的管理器实例,创建结果如下图:
获得数据库实例后,就能对该数据库开展各项操作了。数据库管理器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直接使用的数据结构是ContentValue
类,它类似于映射Map,也提供了put
和get
方法存取键值。区别在于:ContentValue
的键只能是字符串,不能是其他类型。ContentValue
主要用于增加记录和更新记录,对应数据库的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
方法向数据库添加用户信息,而记录读取页面通过query
方法从数据库读取用户信息,并显示出来。
运行App,打开记录保存页面,依次录入信息并将两个用户信息的注册信息保存至数据库,如下两图所示:
再打开记录读取页面,从数据库读取用户注册信息显示在页面上,如下图:
上述演示页面主要用到了数据库记录的添加、查询和删除操作,对应的数据库帮助器关键代码如下,尤其关注里面的insert
、delete
、update
和query
方法:
public class UserDBHelper extends SQLiteOpenHelper {
private static final String TAG = "UserDBHelper";
private static final String DB_NAME = "user.db"; // 数据库的名称
private static final int DB_VERSION = 1; // 数据库的版本号
private static UserDBHelper mHelper = null; // 数据库帮助器实例
private SQLiteDatabase mDB = null; // 数据库的实例
public static final String TABLE_NAME = "user_info"; // 表的名称
public UserDBHelper(Context context) {super(context, DB_NAME, null, DB_VERSION);}
public UserDBHelper(Context context, int version) {super(context, DB_NAME, null, version);}
// 利用单例模式获取数据库帮助器的唯一实例
public static UserDBHelper getInstance(Context context, int version) {
if (0 < version && null == mHelper) {
mHelper = new UserDBHelper(context, version);
} else if (null == mHelper) {
mHelper = new UserDBHelper(context);
}
return mHelper;
}
// 打开数据库的读连接
public SQLiteDatabase openReadLink() {
if (null == mDB || !mDB.isOpen()) {
mDB = mHelper.getReadableDatabase();
}
return mDB;
}
// 打开数据库的写连接
public SQLiteDatabase openWriteLink() {
if (null == mDB || !mDB.isOpen()) {
mDB = mHelper.getWritableDatabase();
}
return mDB;
}
// 关闭数据库连接
public void closeLink() {
if (mDB != null && mDB.isOpen()) {
mDB.close();
mDB = null;
}
}
// 创建数据库,执行建表语句
@Override
public void onCreate(SQLiteDatabase sqLiteDatabase) {
Log.d(TAG, "onCreate");
String drop_sql = "DROP TABLE IF EXISTS " + TABLE_NAME + ";";
Log.d(TAG, "drop_sql:" + drop_sql);
sqLiteDatabase.execSQL(drop_sql);
String create_sql = "CREATE TABLE IF NOT EXISTS " + TABLE_NAME + " ("
+ "_id INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,"
+ "name VARCHAR NOT NULL," + "age INTEGER NOT NULL,"
+ "height INTEGER NOT NULL," + "weight FLOAT NOT NULL,"
+ "married INTEGER NOT NULL," + "update_time VARCHAR NOT NULL"
//演示数据库升级时要先把下面这行注释
+ ",phone VARCHAR" + ",password VARCHAR"
+ ");";
Log.d(TAG, "create_sql:" + create_sql);
sqLiteDatabase.execSQL(create_sql); // 执行完整的SQL语句
}
@Override
public void onUpgrade(SQLiteDatabase sqLiteDatabase, int oldVersion, int newVersion) {
Log.d(TAG, "onUpgrade oldVersion=" + oldVersion + ", newVersion=" + newVersion);
if (newVersion > 1) {
//Android的ALTER命令不支持一次添加多列,只能分多次添加
String alter_sql = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + "phone VARCHAR;";
Log.d(TAG, "alter_sql:" + alter_sql);
sqLiteDatabase.execSQL(alter_sql);
alter_sql = "ALTER TABLE " + TABLE_NAME + " ADD COLUMN " + "password VARCHAR;";
Log.d(TAG, "alter_sql:" + alter_sql);
sqLiteDatabase.execSQL(alter_sql); // 执行完整的SQL语句
}
}
// 根据指定条件删除表记录
public int delete(String condition) {
// 执行删除记录动作,该语句返回删除记录的数目
return mDB.delete(TABLE_NAME, condition, null);
}
// 删除该表的所有记录
public int deleteAll() {
// 执行删除记录动作,该语句返回删除记录的数目
return mDB.delete(TABLE_NAME, "1=1", null);
}
// 往该表添加一条记录
public long insert(UserInfo info) {
List<UserInfo> infoList = new ArrayList<UserInfo>();
infoList.add(info);
return insert(infoList);
}
// 往该表添加多条记录
public long insert(List<UserInfo> infoList) {
long result = -1;
for (int i = 0; i < infoList.size(); i++) {
UserInfo info = infoList.get(i);
List<UserInfo> tempList = new ArrayList<UserInfo>();
// 如果存在同名记录,则更新记录
// 注意条件语句的等号后面要用单引号括起来
if (info.name != null && info.name.length() > 0) {
String condition = String.format("name='%s'", info.name);
tempList = query(condition);
if (tempList.size() > 0) {
update(info, condition);
result = tempList.get(0).rowid;
continue;
}
}
// 如果存在同样的手机号码,则更新记录
if (info.phone != null && info.phone.length() > 0) {
String condition = String.format("phone='%s'", info.phone);
tempList = query(condition);
if (tempList.size() > 0) {
update(info, condition);
result = tempList.get(0).rowid;
continue;
}
}
// 不存在唯一性重复的记录,则插入新记录
ContentValues cv = new ContentValues();
cv.put("name", info.name);
cv.put("age", info.age);
cv.put("height", info.height);
cv.put("weight", info.weight);
cv.put("married", info.married);
cv.put("update_time", info.update_time);
cv.put("phone", info.phone);
cv.put("password", info.password);
// 执行插入记录动作,该语句返回插入记录的行号
result = mDB.insert(TABLE_NAME, "", cv);
if (result == -1) { // 添加成功则返回行号,添加失败则返回-1
return result;
}
}
return result;
}
// 根据条件更新指定的表记录
public int update(UserInfo info, String condition) {
ContentValues cv = new ContentValues();
cv.put("name", info.name);
cv.put("age", info.age);
cv.put("height", info.height);
cv.put("weight", info.weight);
cv.put("married", info.married);
cv.put("update_time", info.update_time);
cv.put("phone", info.phone);
cv.put("password", info.password);
// 执行更新记录动作,该语句返回更新的记录数量
return mDB.update(TABLE_NAME, cv, condition, null);
}
public int update(UserInfo info) {
// 执行更新记录动作,该语句返回更新的记录数量
return update(info, "rowid=" + info.rowid);
}
// 根据指定条件查询记录,并返回结果数据列表
public List<UserInfo> query(String condition) {
String sql = String.format("select rowid,_id,name,age,height," +
"weight,married,update_time,phone,password " +
"from %s where %s;", TABLE_NAME, condition);
Log.d(TAG, "query sql: " + sql);
List<UserInfo> infoList = new ArrayList<UserInfo>();
// 执行记录查询动作,该语句返回结果集的游标
Cursor cursor = mDB.rawQuery(sql, null);
// 循环取出游标指向的每条记录
while (cursor.moveToNext()) {
UserInfo info = new UserInfo();
info.rowid = cursor.getLong(0); // 取出长整型数
info.xuhao = cursor.getInt(1); // 取出整型数
info.name = cursor.getString(2); // 取出字符串
info.age = cursor.getInt(3); // 取出整型数
info.height = cursor.getLong(4); // 取出长整型数
info.weight = cursor.getFloat(5); // 取出浮点数
//SQLite没有布尔型,用0表示false,用1表示true
info.married = (cursor.getInt(6) == 0) ? false : true;
info.update_time = cursor.getString(7); // 取出字符串
info.phone = cursor.getString(8); // 取出字符串
info.password = cursor.getString(9); // 取出字符串
infoList.add(info);
}
cursor.close(); // 查询完毕,关闭数据库游标
return infoList;
}
// 根据手机号码查询指定记录
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;
}
}
优化记住密码功能
在之前实现的记住密码功能中,虽然使用了共享参数实现了记住密码功能,但是该方案只能记住一个用户的登录信息,并且手机号码跟密码没有对应关系,如果换个手机号码登录,前一个用户的登录信息就被覆盖了。真正的记住密码功能应当是这样的:先输入手机号码,然后根据手机号码匹配保存的密码,一个手机号码对应一个密码,从而实现具体手机号码的密码记忆功能。
- 声明一个数据库的帮助器对象,然后在活动页面的
onResume
方法中打开数据库连接,在onPause
方法中关闭数据库连接,示例代码如下:
private UserDBHelper mHelper; // 声明一个用户数据库的帮助器对象
@Override
protected void onResume() {
super.onResume();
mHelper = UserDBHelper.getInstance(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;
}
此外,上面第三点的点击密码框触发查询操作,用到了编辑框的焦点变更事件。就这个案例而言,光标切到密码框触发焦点变更事件,具体处理逻辑要求重写监听器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,先打开登录页面,勾选“记住密码”复选框,并确保本次登录成功。然后再次进入登录页面,输入手机号码后光标还停留在手机框,如下图所示:
接着点击密码框,光标随之跳转到密码框,此时密码框自动填入了该号码对应的密码串,这次实现了真正意义上的记住密码功能,如下图所示:
存储卡
此小节介绍Android的文件存储方式–在存储卡上读写文件,包括:公有存储空间与私有存储空间有什么区别、如何利用存储卡读写文本文件、如何利用存储卡读写图片文件、如何在在App运行的时候动态申请权限等。
私有存储空间与公共存储空间
为了更规范地管理手机存储空间,Android7.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访问公共空间。打开手机的系统设置界面,进入到具体应用的管理页面,会发现该应用的存储访问方式权限被限制了。
当然被禁止访问的只是存储卡的公共空间,App自身的私有空间一九可以正常读写。这缘于Android把存储卡分成了两个区域,一块是所有应用均可范文的公共空间,另一块是只有应用自己才可以访问的专享空间。虽然Android给每个应用都分配了单独的安装目录,但是安装目录的空间很紧张,所以Android在存储卡的“Android/data”目录下给每个应用又单独建了一个文件目录,用来保存应用自己需要处理的临时文件。这个目录只有当前应用才能够读写文件,其他应用是不允许的读写的。由于私有空间本身已经加了访问权限控制,因此它不受系统禁止访问的影响,应用操作自己的文件目录自然不成问题。因为私有的文件目录只有属主应用才能访问,所以一旦属主应用被卸载,那么对应的目录也会被删掉。
既然存储卡分为公共空间和私有空间两部分,它们的空间路径获取方法自然也就有所不同。若想获取公共空间的存储路径,调用的是Environment.getExternalStoragePublicDirectory
方法;若想获取应用私有空间的存储路径,调用的是getExternalFilesDir
方法。下面是分别获取两个空间路劲的代码例子:
// 获取系统的公共存储路径
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) {
// Android10的存储空间默认采取分区方式,此处判断是传统方式还是分区方式
isLegacy = Environment.isExternalStorageLegacy();
}
String desc = "系统的公共存储路径位于" + publicPath +
"\n\n当前App的私有存储路径位于" + privatePath +
"\n\nAndroid7.0之后默认禁止访问公共存储目录" +
"\n\n当前App的存储空间采取" + (isLegacy?"传统方式":"分区方式");
tv_path.setText(desc);
执行代码后获得路径信息如下图:
可见应用的私有空间路径位于“存储卡根目录/Android/data/应用包名称/files/Download”这个目录中。
在存储卡上读写文件
文本文件的读写借助于文件IO流FileOutputStream
和FileInputStream
。其中,FileOutputStream
用于写文件,FileInputStream
用于读文件,它们读写文件的代码例子如下:
// 把字符串保存到指定路径的文本文件
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; // 返回文本文件中的文本字符串
}
接着分别创建写文件页面(FileWriteActivity.java)和读文件页面(FileReadActivity.java),其中写文件页面调用saveText
方法保存文本;而读文本页面调用openText
方法从指定路径的文件中读取文本内容。
运行App,打开文本写入页面,录入注册信息后保存为私有目录里的文本文件,写入界面如下图:
再打开文本读取页面,App自动在私有目录下找到文本文件列表,并展示其中一个文件的文本内容,此时读取页面如下图:
文本文件读写可以转换为对字符串的读写,而图片文件保存的是图像数据,需要专门的位图工具Bitmap
处理。位图对象依据来源不同又分成3种获取方式,分别对应位图工厂BitmapFactory
的下列3个方法:
- decodeResource:从指定的资源文件中获取位图数据。例如下面代码表示从资源文件huawei.jpg获取位图对象:
Bitmap bitmap = BitmapFactory.decodeResource(getResources(), R.drawable.huawei);
- decodeFile:从指定路径的文件中获取位图数据。注意从Android10开始,该方法只适用于私有目录下的图片,不适用公共空间下的图片。
- decodeStream:从指定的输入流中获取位图数据。比如使用IO流打开图片文件,此时文件输入流对象即可为
decodeStream
方法的入参,相应的图片读取代码如下:
// 从指定路径的图片文件中读取位图数据
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) {
// 根据指定的文件路径构建文件输出流对象
try (FileOutputStream fos = new FileOutputStream(path)) {
// 把位图数据压缩到文件输出流中
bitmap.compress(Bitmap.CompressFormat.JPEG, 80, fos);
} catch (Exception e) {
e.printStackTrace();
}
}
接下来完整演示一遍图片文件的读写操作。首先创建图片写入页面,从某个资源图片读取位图数据,再把位图数据保存为私有目录的图片文件,县官代码示例如下:
// 获取当前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);
然后创建图片读取页面,从私有目录找到图片文件,并挑出一张在图像视图上显示,相关代码示例如下:
// 获取当前App的私有下载目录
String mPath = getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS).toString() + "/";
// 获得指定目录下面的所有图片文件
List<File> 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)); // 设置图像视图的路径对象
// 第二种方式:先调用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,先打开图片写入页面,点击“把资源图片保存到存储卡”按钮,此时写入界面如下图:
打开图片读取页面,App自动在私有目录下找到图片文件列表,并展示其中一张图片,此时读取页面如下:
运行时动态申请权限
前面的“公共存储空间与私有存储空间”提到,App若想访问存储卡的公共空间,就要在AndroidManifest.xml
里面添加下述的权限配置:
<!-- 存储卡读写 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
然而即使App声明了完整的存储卡操作权限,从Adroid7.0开始,系统仍然默认禁止该App访问公共空间,直到Adroid 11才解除限制。可以点击链接查看最新说明,当前权限说明如下:
如果当前使用的Android版本是需要手动开启应用存储卡权限,那么在Java代码中处理过程分为3个步骤,详述如下:
- 检查App是否开启了指定权限
权限检查需要调用ContextCompat.checkSelfPermission
方法,该方法的第一个参数为活动实例,第二个为待检查的权限名称,例如存储卡的写权限名为android.permission.WRITE_EXTERNAL_STORAGE
。注意checkSelfPermission
方法的返回值,当它为PackageManager.PERMISSION_GRANTED
时表示已经授权,斗则就是未获取授权。 - 请求系统弹窗,以便用户选择是否开启权限
一旦发现某个权限尚未开启,就得弹窗提示用户手动开启,这个弹窗不是开发者自己写的提醒对话框,而是系统专门用于权限申请的对话框。调用ActivityCompat.requestPermissions
方法,即可命令系统自动弹出权限申请窗口,该方法的第一个参数为活动实例,第二个参数为代申请的权限数组,第三个参数为本次操作的请求代码。 - 判断用户的权限选择结果
然而上面第二步的requestPermissions
方法没有返回值,那怎么判断用户到底选了开启权限还是拒绝权限呢?其实活动页面提供了权限选择的回调方法onRequestPermissionsResult
,如果当前页面请求弹出权限申请窗口,那么该页面的Java代码必须重写onRequestPermissionsResult
方法,并在该方法内部处理用户的权限选择结果。
具体到编码实现上,前两步的权限校验和请求弹窗可以合并到一块,先调用ContextCompat.checkSelfPermission
方法检查某个权限是否已经开启,如果没有开启再调用ActivityCompat.requestPermissions
方法请求系统弹窗。合并之后的检查方法代码示例如下,此处代码支持一次检查一个权限,也支持一次检查多个权限:
// 检查某个权限。返回true表示已启用该权限,返回false表示未启用该权限
public static boolean checkPermission(Activity act, String permission, int requestCode) {
return checkPermission(act, new String[]{permission}, requestCode);
}
// 检查多个权限。返回true表示已完全启用权限,返回false表示未完全启用权限
public static boolean checkPermission(Activity act, String[] permissions, int requestCode) {
boolean result = true;
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
int check = PackageManager.PERMISSION_GRANTED;
// 通过权限数组检查是否都开启了这些权限
for (String permission : permissions) {
check = ContextCompat.checkSelfPermission(act, permission);
if (check != PackageManager.PERMISSION_GRANTED) {
break; // 有个权限没有开启,就跳出循环
}
}
if (check != PackageManager.PERMISSION_GRANTED) {
// 未开启该权限,则请求系统弹窗,好让用户选择是否立即开启权限
ActivityCompat.requestPermissions(act, permissions, requestCode);
result = false;
}
}
return result;
}
注意看到上面代码有判断版本号,只有系统版本大于Android 6.0(版本代号M),才执行后续的权限校验操作。这是因为Android 6.0开始引入运行时权限机制,在Android 6.0之前,只要App在AndroidManifest.xml
中添加了权限配置,则系统会自动给App开启相关权限;但在Android 6.0之后,即便事先添加了权限配置,系统也不会自动开启权限,而是要开发者在App运行时判断权限的开关情况,再据此动态申请未获授权的权限。
回到活动页面代码,一方面增加权限校验入口,比如点击某个按钮后触发权限检查操作,其中Manifest.permission.WRITE_EXTERNAL_STORAGE
表示存储卡权限,入口代码如下:
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R ||
PermissionUtil.checkPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE,
R.id.btn_external_write % 65536)) {
Intent intent = new Intent(this, FileWriteActivity.class);
intent.putExtra("is_external", true);
startActivity(intent);
}
另一方面还要重写活动的onRequestPermissionsResult
方法,在方法内部校验用户的选择结果,若用户同意授权,就执行后续业务;若用户拒绝授权,只能提示用户无法开展后续业务了。重写后的方法代码如下:
@Override
public void onRequestPermissionsResult ( int requestCode, String[] permissions, int[] grantResults){
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
// requestCode不能为负数,也不能大于2的16次方即65536
if (requestCode == R.id.btn_external_write % 65536) {
if (PermissionUtil.checkGrant(grantResults)) { // 用户选择了同意授权
Intent intent = new Intent(this, FileWriteActivity.class);
startActivity(intent);
} else {
//ToastUtil.show(this, "需要允许存储卡权限才能写入公共空间噢");
Toast.makeText(this, "需要允许存储卡权限才能写入公共空间噢", Toast.LENGTH_SHORT).show();
}
}
}
以上代码为了简化逻辑,将结果校验操作封装为PermissionUtil
的checkGrant
方法,该方法遍历授权结果数组,依次检查每个权限是否都是得到了授权了。详细的方法代码如下:
// 检查权限结果数组,返回true表示都已经获得授权。返回false表示至少有一个未获得授权
public static boolean checkGrant(int[] grantResults) {
boolean result = true;
if (grantResults != null) {
for (int grant : grantResults) { // 遍历权限结果数组中的每条选择结果
if (grant != PackageManager.PERMISSION_GRANTED) { // 未获得授权
result = false;
}
}
} else {
result = false;
}
return result;
}
代码修改好后,运行App,一开始未授权开启存储卡权限,点击按钮btn_external_write
当即就会弹出存储卡申请窗口。
点击弹窗上的“始终允许”按钮,表示同意赋予存储卡读写权限,然后系统自动给App开启了存储卡权限,并执行后续处理逻辑,也就是跳到FileWriteActivity页面,在该页面即可访问公共空间的文件里,但在Android 10系统中,即使授权通过,App仍然无法访问公共空间,这是因为Android 10默认开启沙箱模式,不允许直接使用公共空间的文件路径,此时要修改AndroidManifest.xml
,给application
节点添加如下的android:requestLegacyExternalStorage
属性:
<application
android:requestLegacyExternalStorage="true" />
从Android 11开始,为了让应用在升级时也能正常访问公共空间,还得修改AndroidManifest.xml
,给application
节点添加如下的android:requestLegacyExternalStorage
属性,表示暂时关闭沙箱模式:
android:requestLegacyExternalStorage="true"
除了存储卡的读写权限,还有部分权限也要求在运行时动态申请,这些权限名称的取值说明见下表:
代码中的权限名称 | 权限说明 |
---|---|
android.permission.READ_EXTERNAL_STORAGE | 读存储卡 |
android.permission.WRITE_EXTERNAL_STORAGE | 写存储卡 |
android.permission.READ_CONTACTS | 读联系人 |
android.permission.WRITE_CONTACTS | 写联系人 |
android.permission.SEND_SMS | 发送短信 |
android.permission.RECEIVE_SMS | 接收短息 |
android.permission.READ_CALL_LOG | 读通话记录 |
android.permission.WRITE_CALL_LOG | 写通话记录 |
android.permission-group.CAMERA | 相机 |
android.permission.RECORD_AUDIO | 录音 |
android.permission.ACCESS_FINE_LOCATION | 精确定位 |
应用组件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。修改后的MainApplication节点示例如下:
<application
android:name=".MainApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:requestLegacyExternalStorage="true"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.Chapter06"
tools:targetApi="31">
- 在Java代码的包名目录下创建MainApplication.java,要求该类继承Application,继承之后可供重写的方法主要有3个。
onCreate:在App启动时调用。
onTerminate:在App终止时调用(按字面意思)。
onConfigurationChanged:在配置改变时调用,例如从竖屏变为横屏。
光看字面意思的话,与生命周期有关的方法是onCreate和onTerminate,那么重写这两个方法,并在重写后的方法中打印日志,修改后的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类数据:
- 会频繁读取的信息,例如用户名、手机号码等。
- 不方便由意图传递的数据,例如位图对象、非字符串类型的集合等。
- 容易因频繁分配内存而导致内存泄露的对象,例如Handle处理器实例等。
要想通过Application实现全局内存的读写,得完成一下3项工作:
- 编写一个继承自Application的新类MainApplication。该类采用单例模式,内部先声明自身类的一个静态成员对象,在创建App时把自身赋值给这个静态对象,然后提供该对象的获取方法getInstance。具体代码如下:
public class MainApplication extends Application {
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();
mApp = this; // 在打开应用时对静态的应用实例赋值
}
}
- 在活动页面代码中调用MainApplication的getInstance方法,获得它的是一个静态对象,再通过该对象访问MainApplication的公共变量和公共方法。
- 不要忘了在AndroidManifest.xml中注册新定义的Application类名,也就是给application节点增加
android:name
属性,其值为.MainApplication
。
接下来演示如何读写内存中的全局变量。首先分别创建写内存页面和读内存页面,其中写内存页面把用户的注册信息保存到全局变量infoMap,完整代码见AppWriteActivity.java;而读内存页面从全局变量infoMap读取用户的注册信息,完整代码见AppReadActivity.java。
运行App,先打开内存写入界面,录入注册信息后保存至全局变量,此时写入界面如下图:
打开内存读取页面,App自动从全局变量获取注册信息,并展示拼接后的信息文本,完整代码见AppReadActivity.java。读取内容界面如下:
避免方法数过多的问题
一个大规模的App工程,往往引入数量繁多的第三方开发库,其中既有官方的Jetpack库,也有第三方厂商的开源包。有时候运行这种App会报错“cannot fit requested classes in a single dex file (# methods:65894>65536)”,意思是App内部引用的方法数量超过了65536个,导致App异常退出。
原来Android的每个App代码都放在一个dex文件中,系统会把内部方法的索引保存在一个链表结构里,由于这个链表的最大长度变量是short类型(short类型的数字占两个字节共16位),使得链表的最大长度不能超过65536(2的16次方),因此若App方法数超过65536的话,链表索引溢出就报错了。为了解决方法数过多的问题,Android推出了名叫MultiDex的解决方案,也就是在打包时把应用分成多个dex文件,每个dex文件中的方法数量均不超过65536个,因此规避了方法数过多的限制。
若想让App工程支持MultiDex,需要对其略加改造,具体改造步骤说明如下:
- 修改模块的build.gradle文件,往dependencies节点添加下面一行配置,表示导入指定版本的MultiDex库:
implementation("androidx.multidex:multidex:2.0.1")
- 在defaultConfig节点添加以下配置,表示开启多个dex功能:
multiDexEnabled = true // 避免方法数最多65536的问题
- 编写自定的Application,注意该Application类必须继承MultiDexApplication,代码如下:
public class MainApplication extends MultiDexApplication {
// 此处省略内部方法与属性代码
}
- 打开AndroidManifest.xml,给application节点的
android:name
属性设置自定义的Application,代码如下:
android:name=".MainApplication"
- 重新编译App工程,之后运行的App就不会再出现方法数过多的问题了。
利用Room简化数据库操作
虽然Android提供了数据库帮助器,但是开发者在进行数据库编程时仍有诸多不便,比如每次增加一张新表,开发者都得手工实现以下代码逻辑:
- 重写数据库帮助器的
onCreate
方法,添加该表的建表语句。 - 在插入记录之时,必须将数据库实例的属性值逐一赋给该表的各字段。
- 在查询记录之时,必须遍历结果集游标,把各字段值逐一赋给数据实例。
- 每次读写操作之前,都要先开启数据库连接;读写操作之后,又要关闭数据库连接。
上述的处理操作无疑存在不少重复劳动,数年来引得开发者叫苦连连。为此各类数据库处理框架纷纷涌现,包括GreenDao、ormLite、Realm等,可谓百花齐放。眼见SQLite渐渐乏人问津,谷歌公司干脆整了个自己的数据库框架–Room
,该框架同样基于SQLite,但它通过注解技术极大地简化了数据库操作,减少了原来相当大一部分工作量。
由于Room并未集成到SDK中,而是作为第三方框架提供,因此要修改模块的build.gradle文件,往dependencies节点添加下面的两行配置,表示导入指定版本的Room库:
implementation("androidx.room:room-runtime:2.6.1")
annotationProcessor("androidx.room:room-compiler:2.6.1")
导入Room库之后,还要编写若干对应的代码文件。以录入图书信息为例,此时要对图书信息表进行增删改查,则具体的编码过程分为下列5个步骤:
1.编写图书信息表对应的实体类
假设图书信息类名为BookInfo
,且它的各属性与图书信息表的各字段一一对应,那么要给该类添加@Entity
注解,表示该类是Room专用的数据类型,对应的表名称也叫做BookInfo
。如果BookInfo表的name字段是该表的主键,则需要给BookInfo类的name属性添加@PrimaryKey
和@NonNull
两个注解,表示该字段是个非空主键。下面是BookInfo类的定义代码例子:
@Entity
public class BookInfo {
@PrimaryKey // 该字段是主键,不能重复
@NonNull // 主键必须是非空字段
private String name; // 书籍名称
private String author; // 作者
private String press; // 出版社
private double price; // 价格
// 以下省略各属性的set***方法和get***方法
}
2.编写图书信息表对应的持久化类
所谓持久化,指的是将数据保存到磁盘而非内存,其实等同于增删改等SQL语句。假设图书信息的持久化类名叫做BookDao
,那么该类必须添加@Dao
注解,内部的记录查询方法必须添加@Query
注解,记录插入方法必须添加@Insert
注解,记录更新方法必须添加@Update
注解,记录删除方法必须添加@Delete
注解(带条件的删除方法除外)。对于记录查询方法,允许在@Query
之后补充具体的查询语句以及查询条件;对于记录插入方法与记录更新方法,需明确出现重复记录时要采取哪种处理策略。下面是BookDao类的定义代码的例子:
@Dao
public interface BookDao {
@Query("SELECT * FROM BookInfo") // 设置查询语句
List<BookInfo> queryAllBook(); // 加载所有书籍信息
@Query("SELECT * FROM BookInfo WHERE name = :name") // 设置带条件的查询语句
BookInfo queryBookByName(String name); // 根据名字加载书籍
@Insert(onConflict = OnConflictStrategy.REPLACE) // 记录重复时替换原记录
void insertOneBook(BookInfo book); // 插入一条书籍信息
@Insert
void insertBookList(List<BookInfo> bookList); // 插入多条书籍信息
@Update(onConflict = OnConflictStrategy.REPLACE)// 出现重复记录时替换原记录
int updateBook(BookInfo book); // 更新书籍信息
@Delete
void deleteBook(BookInfo book); // 删除书籍信息
@Query("DELETE FROM BookInfo WHERE 1=1") // 设置删除语句
void deleteAllBook(); // 删除所有书籍信息
}
3.编写图书信息表对应的数据库类
因为现有数据库然后才有表,所以图书信息表还得放到某个数据库里,这个默认的图书数据库要从RoomDatabase
派生而来,并添加@Database
注解。下面是数据库类BookDatabase的定义代码例子:
//entities表示该数据库有哪些表,version表示数据库的版本号
//exportSchema表示是否导出数据库信息的json串,建议设为false,若设为true还需指定json文件的保存路径
@Database(entities = {BookInfo.class},version = 1, exportSchema = false)
public abstract class BookDatabase extends RoomDatabase {
// 获取该数据库中某张表的持久化对象
public abstract BookDao bookDao();
}
4.在自定义的Application类中声明图书数据库的唯一实例
为了避免重复打开数据库造成的内存泄露问题,每个数据库在App运行过程中理应只有一个实例,此时要求开发者自定义新的Application类,在该类中声明并获取图书数据库的实例,并将自定义的Application类设为单例类,保证App在运行之时有且仅有一个应用实例。下面是自定义Application类的代码例子:
public class MainApplication extends Application {
private static MainApplication mApp; // 声明一个当前应用的静态实例
private BookDatabase bookDatabase; // 声明一个书籍数据库对象
// 利用单例模式获取当前应用的唯一实例
public static MainApplication getInstance() {
return mApp;
}
@Override
public void onCreate() {
super.onCreate();
mApp = this; // 在打开应用时对静态的应用实例赋值
// 构建书籍数据库的实例
bookDatabase = Room.databaseBuilder(mApp, BookDatabase.class,"BookInfo")
.addMigrations() // 允许迁移数据库(发生数据库变更时,Room默认删除原数据库再创建新数据库。如此一来原来的记录会丢失,故而要改为迁移方式以便保存原有记录)
.allowMainThreadQueries() // 允许在主线程中操作数据库(Room默认不能在主线程中操作数据库)
.build();
}
// 获取书籍数据库的实例
public BookDatabase getBookDB(){
return bookDatabase;
}
}
5.在操作图书信息表的地方获取数据表的持久化对象
持久化对象的获取代码很简单,只需要下面一行代码就够了:
// 从App实例中获取唯一的书籍持久化对象
BookDao bookDao = MainApplication.getInstance().getBookDB().bookDao();
完成以上5个编码步骤之后,接着调用持久化对象的query***、insert***、update***、delete***等方法,就能实现图书信息的增删改查操作了。例程的图书信息演示页面有两个,分别是记录保存页面和记录读取页面。其中记录保存页面通过insertOneBook方法向数据库添加图书信息,完整代码见RoomWriteActivity.java;而记录读取页面通过queryAllBook方法从数据库读取图书信息,完整代码见RoomReadActivity.java。
运行App,打开记录保存页面,输入数据并保存至数据库,如下图所示:
打开记录读取页面,从数据库读取图书信息并展示在页面上如下图:
共享数据
此节介绍Android的四大组件之一ContentProvider的基本概念和常见用法:首先说明如何使用内容提供器封装内部数据的外部访问接口,然后阐述如何使用内容解析器通过外部接口操作内部数据,最后叙述如何利用内容解析器读写联系人信息,以及如何利用内容观察器监听收到的短信内容。
通过ContentProvider封装数据
Android提供了四大组件,分别是活动Activity、广播Broadcast、服务Service和内容提供器ContentProvider。其中内容提供器涵盖与内部数据存取有关的一系列组件,完整的内容组件由内容提供器ContentProvider、内容解析器ContentResolver、内容观察器ContentObserver三部分组成。
ContentProvider给App存取内部数据提供了统一的外部接口,让不同的应用之间得以互相共享数据。ContentProvider可操作当前设备其他应用的内部数据,它是一种中间层次的数据存储形式。
在实际编码中,ContentProvider只是服务端App存取数据的抽象类,开发者需要在其基础上实现一个完整的内容提供器,并重写下列数据库的管理方法。
- onCreate:创建数据库并获得数据库连接。
- insert:插入数据。
- delete:删除数据。
- update:更新数据。
- query:查询数据,并返回结果集的游标。
- getType:获取内容提供器支持的数据类型。
这些方法看起来是不是很像SQLite?没错,ContentProvider作为中间接口,本身并不直接保存数据,而是通过SQLiteOpenHelper与SQLiteDatabase间接操作底层的数据库。所以想要使用ContentProvider,首先得实现SQLite得数据帮助器,然后由ContentProvider封装对外接口。以封装用户信息为例,具体步骤主要分成下面3步。
1.编写用户信息表的数据库帮助器
这个数据库帮助器就是常规的SQLite操作代码,实现过程参见此篇文章的数据库帮助器SQLiteOpenHelper
部分,完整代码参见database\UserDBHelper.java。
2.编写内容提供器的基础字段类
该类需要实现接口BaseColumns,同时加入几个常量定义。详细代码示例如下:
public class UserInfoContent implements BaseColumns {
// 这里的名称必须与AndroidManifest.xml里的android:authorities保持一致
public static final String AUTHORITIES = "com.example.chapter06.provider.UserInfoProvider";
// 内容提供器的外部表名
public static final String TABLE_NAME = UserDBHelper.TABLE_NAME;
// 访问内容提供器的URI
public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITIES + "/user");
// 下面是该表的各个字段名称
public static final String USER_NAME = "name";
public static final String USER_AGE = "age";
public static final String USER_HEIGHT = "height";
public static final String USER_WEIGHT = "weight";
}
3.通过右键菜单创建内容提供器
右击App模块的包名目录,在弹出的右键菜单中依次选择New->Other->Content Provider,打开如下图所示的组件创建对话框。
在创建对话框的Class Name一栏填写内容提供器的名称,比如UserInfoProvider;在URI Authorities一栏填写URI的授权串,比如com.example.chapter06.provider.UserInfoProvider
;然后单击对话框右下角的finished按钮,完成提供器的创建操作。
上述创建过程会自动修改App模块的两处地方,一处是往AndroidManifest.xml添加内容提供器的注册配置,配置信息示例如下:
<!-- provider的authorities属性值需要与Java代码的AUTHORITIES保持一致 -->
<provider
android:name=".provider.UserInfoProvider"
android:authorities="com.example.chapter06.provider.UserInfoProvider"
android:enabled="true"
android:exported="true" />
另一处是在包名目录下生成名为UserInfoProvider.java的代码文件,打开一看发现该类继承了ContentProvider,并且提示重写了onCreate、insert、delete、query、update、getType等方法,以便对数据进行增删改查等操作。这个提供器代码显然只有一个框架,还需补充详细的实现代码,为此重写onCreate方法,在此获取用户信息表的数据库帮助器实例,其他insert、delete、query等方法也要加入对应的数据库操作代码,修改之后的内容提供器代码如下:
public class UserInfoProvider extends ContentProvider {
private final static String TAG = "UserInfoProvider";
private UserDBHelper userDB; // 声明一个用户数据库的帮助器对象
public static final int USER_INFO = 1; // Uri匹配时的代号
public static final UriMatcher uriMatcher = new UriMatcher(UriMatcher.NO_MATCH);
static { // 往Uri匹配器中添加指定的数据路径
uriMatcher.addURI(UserInfoContent.AUTHORITIES, "/user", USER_INFO);
}
// 创建ContentProvider时调用,可在此获取具体的数据库帮助器实例
@Override
public boolean onCreate() {
userDB = UserDBHelper.getInstance(getContext(), 1);
return true;
}
// 插入数据
@Override
public Uri insert(Uri uri, ContentValues values) {
if (uriMatcher.match(uri) == USER_INFO) { // 匹配到了用户信息表
// 获取SQLite数据库的写连接
SQLiteDatabase db = userDB.getWritableDatabase();
// 向指定的表插入数据,返回记录的行号
long rowId = db.insert(UserInfoContent.TABLE_NAME, null, values);
if (rowId > 0) { // 判断插入是否执行成功
// 如果添加成功,就利用新记录的行号生成新的地址
Uri newUri = ContentUris.withAppendedId(UserInfoContent.CONTENT_URI, rowId);
// 通知监听器,数据已经改变
getContext().getContentResolver().notifyChange(newUri, null);
}
db.close(); // 关闭SQLite数据库连接
}
return uri;
}
// 根据指定条件删除数据
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
int count = 0;
if (uriMatcher.match(uri) == USER_INFO) { // 匹配到了用户信息表
// 获取SQLite数据库的写连接
SQLiteDatabase db = userDB.getWritableDatabase();
// 执行SQLite的删除操作,并返回删除记录的数目
count = db.delete(UserInfoContent.TABLE_NAME, selection, selectionArgs);
db.close(); // 关闭SQLite数据库连接
}
return count;
}
// 根据指定条件查询数据库
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
Cursor cursor = null;
if (uriMatcher.match(uri) == USER_INFO) { // 匹配到了用户信息表
// 获取SQLite数据库的读连接
SQLiteDatabase db = userDB.getReadableDatabase();
// 执行SQLite的查询操作
cursor = db.query(UserInfoContent.TABLE_NAME,
projection, selection, selectionArgs, null, null, sortOrder);
// 设置内容解析器的监听
cursor.setNotificationUri(getContext().getContentResolver(), uri);
}
return cursor; // 返回查询结果集的游标
}
// 获取Uri支持的数据类型,暂未实现
@Override
public String getType(Uri uri) {
throw new UnsupportedOperationException("Not yet implemented");
}
// 更新数据,暂未实现
@Override
public int update(Uri uri, ContentValues values, String selection, String[] selectionArgs) {
throw new UnsupportedOperationException("Not yet implemented");
}
}
经过以上3个步骤之后,便完成了服务端App的接口封装工作,接下来再由其他App去访问服务端App的数据。
通过ContentResolver访问数据
上一小节提到了利用ContentProvider封装服务端App的数据,如果客户端App想访问对方的内部数据,就要借助内容解析器ContentResolver。内容解析器是客户端App操作服务端数据的工具,与之对应的内容提供器则是服务端的数据接口。在活动代码中调用getContentResolver
方法,即可获取内容解析器的实例。
ContentResolver提供的方法与ContentProvider一一对应,比如insert、delete、query、update、getType等,甚至连方法的参数类型都雷同。以添加操作为例,针对前面UserInfoProvider提供的数据接口,下面由内容解析器调用insert方法,使之往内容提供器中插入一条用户信息,记录添加代码如下:
// 添加一条用户记录
private void addUser(UserInfo user) {
ContentValues name = new ContentValues();
name.put("name", user.name);
name.put("age", user.age);
name.put("height", user.height);
name.put("weight", user.weight);
name.put("married", 0);
name.put("update_time", DateUtil.getNowDateTime(""));
// 通过内容解析器往指定Uri添加用户信息
getContentResolver().insert(UserInfoContent.CONTENT_URI, name);
}
至于删除操作就更简单了,只要下面一行代码就删除了所有记录:
getContentResolver().delete(UserInfoContent.CONTENT_URI, "1=1", null);
查询操作稍微复杂一些,调用query方法会返回游标对象,这个游标正是SQLite的游标Cursor,详细用法参见“数据库帮助器SQLiteOpenHelper”。query方法的参数有好几个,具体说明如下(依参数顺序排列):
- uri:Uri类型,指定本次操作的数据表路径。
- projection:字符串数组类型,指定将要查询的字段名称列表。
- selection:字符串类型,执行查询条件。
- selectionArgs:字符串数组类型,指定查询条件中的参数取值列表。
- sortOrder:字符串类型,指定排序条件。
下面是调用query方法从内容提供器查询所有用户信息的代码例子:
// 显示所有的用户记录
private void showAllUser() {
List<UserInfo> userList = new ArrayList<UserInfo>();
// 通过内容解析器从指定Uri中获取用户记录的游标
Cursor cursor = getContentResolver().query(UserInfoContent.CONTENT_URI, null, null, null, null);
// 循环取出游标指向的每条用户记录
while (cursor.moveToNext()) {
UserInfo user = new UserInfo();
user.name = cursor.getString(cursor.getColumnIndex(UserInfoContent.USER_NAME));
user.age = cursor.getInt(cursor.getColumnIndex(UserInfoContent.USER_AGE));
user.height = cursor.getInt(cursor.getColumnIndex(UserInfoContent.USER_HEIGHT));
user.weight = cursor.getFloat(cursor.getColumnIndex(UserInfoContent.USER_WEIGHT));
userList.add(user); // 添加到用户信息列表
}
cursor.close(); // 关闭数据库游标
String contactCount = String.format("当前共找到%d个用户", userList.size());
tv_desc.setText(contactCount);
ll_list.removeAllViews(); // 移除线性布局下面的所有下级视图
for (UserInfo user : userList) { // 遍历用户信息列表
String contactDesc = String.format("姓名为%s,年龄为%d,身高为%d,体重为%f\n",
user.name, user.age, user.height, user.weight);
TextView tv_contact = new TextView(this); // 创建一个文本视图
tv_contact.setText(contactDesc);
tv_contact.setTextColor(Color.BLACK);
tv_contact.setTextSize(17);
int pad = Utils.dip2px(this, 5);
tv_contact.setPadding(pad, pad, pad, pad); // 设置文本视图的内部间距
ll_list.addView(tv_contact); // 把文本视图添加至线性布局
}
}
接下来分别演示通过内容解析器添加和查询用户信息的过程,其中记录添加页面为ContentWriteActivity.java,记录查询页面为ContentReadActivity.java。运行App,先打开记录添加页面,输入用户信息后点击“添加用户信息”按钮,由内容解析器执行插入操作,此时添加界面显示出来如下图:
接着打开记录查询页面,内容解析器自动执行查询操作,并将查到的用户信息一一显示出来,此时查询界面如下图:
对比添加页面和查询页面的用户信息,可知成功查到了新增的用户记录。
利用ContentResolver读写联系人
在实际开发中,普通App很少会开放数据接口给其他应用访问,作为服务端接口的ContentProvider基本用不到。内容组件能够派上用场的情况,往往是App想要访问系统应用的通讯数据,比如查看联系人、短信、通话记录,以及对这些通讯数据进行增删改查。
在访问系统的通讯录数据之前,得现在AndroidManifest.xml中添加相应的权限配置,常见的通讯权限配置主要有下面几个:
<!-- 联系人/通讯录。包括读联系人、写联系人 -->
<uses-permission android:name="android.permission.READ_CONTACTS" />
<uses-permission android:name="android.permission.WRITE_CONTACTS" />
<!-- 短信。包括发送短信、接收短信、读短信 -->
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />
<uses-permission android:name="android.permission.READ_SMS" />
从Android 6.0开始,上述的通讯权限默认是关闭的,必须在运行App的时候动态申请相关权限,详细的权限申请过程参见“运行时动态申请权限”小节。
尽管系统允许App通过内容解析器修改联系人列表,但操作过程比较繁琐,因为一个联系人可能有多个电话号码,还可能有多个邮箱。所以系统通讯录将其设计为3张表,分别是联系人基本信息表、联系号码表、联系邮箱表,于是每添加一位联系人,就要调用至少三次insert方法。下面是往手机通讯录中添加联系人的代码例子:
// 往手机通讯录添加一个联系人信息(包括姓名、电话号码、电子邮箱)
public static void addContacts(ContentResolver resolver, Contact contact) {
// 构建一个指向系统联系人提供器的Uri对象
Uri raw_uri = Uri.parse("content://com.android.contacts/raw_contacts");
ContentValues values = new ContentValues(); // 创建新的配对
// 往 raw_contacts 添加联系人记录,并获取添加后的联系人编号
long contactId = ContentUris.parseId(resolver.insert(raw_uri, values));
// 构建一个指向系统联系人数据的Uri对象
Uri uri = Uri.parse("content://com.android.contacts/data");
ContentValues name = new ContentValues(); // 创建新的配对
name.put("raw_contact_id", contactId); // 往配对添加联系人编号
// 往配对添加“姓名”的数据类型
name.put("mimetype", "vnd.android.cursor.item/name");
name.put("data2", contact.name); // 往配对添加联系人的姓名
resolver.insert(uri, name); // 往提供器添加联系人的姓名记录
ContentValues phone = new ContentValues(); // 创建新的配对
phone.put("raw_contact_id", contactId); // 往配对添加联系人编号
// 往配对添加“电话号码”的数据类型
phone.put("mimetype", "vnd.android.cursor.item/phone_v2");
phone.put("data1", contact.phone); // 往配对添加联系人的电话号码
phone.put("data2", "2"); // 联系类型。1表示家庭,2表示工作
resolver.insert(uri, phone); // 往提供器添加联系人的号码记录
ContentValues email = new ContentValues(); // 创建新的配对
email.put("raw_contact_id", contactId); // 往配对添加联系人编号
// 往配对添加“电子邮箱”的数据类型
email.put("mimetype", "vnd.android.cursor.item/email_v2");
email.put("data1", contact.email); // 往配对添加联系人的电子邮箱
email.put("data2", "2"); // 联系类型。1表示家庭,2表示工作
resolver.insert(uri, email); // 往提供器添加联系人的邮箱记录
}
同理,联系人读取代码也分成3个步骤,先查出联系人的基本信息,再查询联系人号码,最后查询联系人邮箱,详细代码参见CommunicationUtil.java的readAllContacts方法。
接下来演示联系人信息的访问过程。分别创建联系人的添加页面和查询页面,其中添加页面的完整代码见ContactAddActivity.java,查询页面的完整代码见ContactReadActivity.java。首先在添加页面输入联系人信息,点击“添加联系人”按钮调用addContacts
方法写入联系人数据,此时添加界面如下图:
然后打开联系人联系人查询页面,App自动调用readAllContacts方法查出所有的联系人,并显示联系人列表,如下图:
利用ContentObserver监听短信
ContentResolver获取数据采用的是主动查询方式,有查询就有数据,没查询就没数据。然而有时不但要获取以往的数据,还要实时获取新增的数据,最常见的业务场景是短信验证码。电商App经常在用户注册或付款时发送验证码短信,为了提用户省事,App通常会监控手机刚收到的短信验证码,并自动填写验证码输入框。这时就用到了内容观察器ContentObserver,事先给目标内容注册一个观察器,目标内容的数据一旦发生变化,就马上触发观察器的监听事件,从而执行开发者预先定义的代码。
内容观察器的用法与内容提供器类似,也要从ContentObserver派生一个新的观察器,然后通过ContentReserver对象调用相应的方法注册或注销观察器。下面是内容解析器与内容观察器之间的交互方法说明。
- registerContentObserver:内容解析器要注册内容观察器。
- unregisterContentObserver:内容解析器要注销内容观察器。
- notifyChange:通知内容观察器发生了数据变化,此时会触发观察器的onChange方法。notifyChange的调用时机参见“通过ContentProvider封装数据”的insert代码。
为了让读者更好理解,下面举一个实际应用的例子。手机号码的每月流量限额由移动运营商指定,以中国移动为例,只要流量校准短信发给运营商客服号码(如发送18到10086),运营商就会回复用户本月的流量数据,包括月流量额度、已使用流量、未使用流量等信息。手机App只需监控10086发来的短信内容,即可自动获取当前号码的流量详情。
下面是利用内容观察器实现流量校准的关键代码片段:
private Handler mHandler = new Handler(Looper.myLooper()); // 声明一个处理器对象
private SmsGetObserver mObserver; // 声明一个短信获取的观察器对象
private static Uri mSmsUri; // 声明一个系统短信提供器的Uri对象
private static String[] mSmsColumn; // 声明一个短信记录的字段数组
// 初始化短信观察器
private void initSmsObserver() {
//mSmsUri = Uri.parse("content://sms/inbox");
//Android5.0之后似乎无法单独观察某个信箱,只能监控整个短信
mSmsUri = Uri.parse("content://sms"); // 短信数据的提供器路径
mSmsColumn = new String[]{"address", "body", "date"}; // 短信记录的字段数组
// 创建一个短信观察器对象
mObserver = new SmsGetObserver(this, mHandler);
// 给指定Uri注册内容观察器,一旦发生数据变化,就触发观察器的onChange方法
getContentResolver().registerContentObserver(mSmsUri, true, mObserver);
}
// 在页面销毁时触发
protected void onDestroy() {
super.onDestroy();
getContentResolver().unregisterContentObserver(mObserver); // 注销内容观察器
}
// 定义一个短信获取的观察器
private static class SmsGetObserver extends ContentObserver {
private Context mContext; // 声明一个上下文对象
public SmsGetObserver(Context context, Handler handler) {
super(handler);
mContext = context;
}
// 观察到短信的内容提供器发生变化时触发
public void onChange(boolean selfChange) {
String sender = "", content = "";
// 构建一个查询短信的条件语句,移动号码要查找10086发来的短信
String selection = String.format("address='10086' and date>%d",
System.currentTimeMillis() - 1000 * 60 * 1); // 查找最近一分钟的短信
// 通过内容解析器获取符合条件的结果集游标
Cursor cursor = mContext.getContentResolver().query(
mSmsUri, mSmsColumn, selection, null, " date desc");
// 循环取出游标所指向的所有短信记录
while (cursor.moveToNext()) {
sender = cursor.getString(0); // 短信的发送号码
content = cursor.getString(1); // 短信内容
Log.d(TAG, "sender="+sender+", content="+content);
break;
}
cursor.close(); // 关闭数据库游标
mCheckResult = String.format("发送号码:%s\n短信内容:%s", sender, content);
// 依次解析流量校准短信里面的各项流量数值,并拼接流量校准的结果字符串
String flow = String.format("流量校准结果如下:总流量为:%s;已使用:%s" +
";剩余流量:%s", findFlow(content, "总流量为"),
findFlow(content, "已使用"), findFlow(content, "剩余"));
if (tv_check_flow != null) { // 离开该页面后就不再显示流量信息
tv_check_flow.setText(flow); // 在文本视图显示流量校准结果
}
super.onChange(selfChange);
}
}
运行App,点击校验按钮发送流量校验短信,接着就会收到运营商发来的短信内容。同时App监听刚接收的流量信息,从中解析得到当前的流量数值。则可证明通过观察器实时获取了最新的短信记录。
总结一下系统开放给普通应用访问的常用URI,详细的URI取值说明见下表。
内容名称 | URI常量名 | 实际路径 |
---|---|---|
联系人基本信息 | ContactsContract.Contacts.CONTENT_URI | content://com.android.contacts/contacts |
联系人电话号码 | ContactsContract.CommonDataKinds.Phone.CONTENT_URI | content://com.android.contacts/data/phones |
联系人邮箱 | ContactsContract.CommonDataKinds.Email.CONTENT_URI | content://com.android.contacts/data/emails |
短信 | ContentResolver.SMS_INBOX_CONTENT_URI | content://sms/inbox |
彩信 | ContentResolver.SMS_CONVERSATIONS_CONTENT_URI | content://mms-sms/conversations |
通话记录 | ContentResolver.Calls.CONTENT_URI | content://call_log/calls |
工程源码
点击源码链接即可下载工程源码。