安卓数据存储(键值对、数据库、存储卡、应用组件Application、共享数据)

键值对

此小节介绍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格式的特点,共享参数主要用于如下场合:

  1. 简单且孤立的数据。若是复杂且相互关联的数据,则要保存在关系数据库中。
  2. 文本形式的数据。若是二进制的数据,则要保存至文件。
  3. 需要持久化存储的数据。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处:

  1. 声明一个共享参数对象,并在onCreate方法中调用getSharedPreferences方法获取共享参数的实例。
  2. 登录成功时,如果用户勾选了“记住密码”复选框,就使用共享参数保存手机号码与密码。也就是在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(); 												// 提交编辑器中的修改
}
  1. 再次打开登录页面时,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 DataStoreProto 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语法有所出入,相关的注意点说明见下:

  1. SQL语句不区分大小写,无论是createtable这类关键词,还是表格名称、字段名称,都不区分大小写。唯一区分大小写的是被单引号括起来的字符串值。
  2. 为避免重复建表,应加上IF NOT EXISTS关键词,例如CREATE TABLE IF NOT EXISTS 表格名称 ......
  3. SQLite支持整型INTEGER、长整型LONG、字符串型VARCHAR、浮点数FLOAT。但不支持布尔类型。布尔类型的数据要使用整型保持,如果直接保存布尔数据,在入库时SQLite会自动将它转为0或1,其中0表示false,1表示true。
  4. 建表时需要唯一标识字段,它的字段名为_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类,分别说明如下:

  1. 添加记录
    记录的添加动作由insert命令完成,格式为INSERT INTO 表格名称(以逗号分隔的字段名列表) VALUES(以逗号分隔的字段值列表);。下面是往用户信息表插入一条记录的SQL语句例子:
INSERT INTO user_info (name,age,height,weight,married,update_time)
VALUES ('张飞',20,170,50,0,'20200504')
  1. 删除记录
    记录的删除动作由delete命令完成,格式为DELETE FROM 表格名称 WHERE 查询条件;,其中查询条件的表达式形如字段名=字段值,多个字段的条件交集通过AND连接,条件并集通过OR连接。下面是从用户信息表指定记录的SQL语句例子:
DELETE FROM user_info WHERE name='张三';
  1. 修改记录
    记录的修改动作由update命令完成,格式为UPDATE 表格名称 SET 字段名=字段值 WHERE 查询条件;。下面是对用户信息表更新指定记录的SQL语句例子:
UPDATE user_info SET married=1 WHERE name='张三';
  1. 查询记录
    记录的查询动作由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类,例举如下:

  1. 管理类,用于数据库层面的操作
    openDatabase:打开指定路径的数据库。
    isOpen:判断数据库是否打开。
    close:关闭数据库。
    getVersion:获取数据库的版本号。
    setVersion:设置数据库版本号。
  2. 事务类,用于事务层面的操作
    beginTransaction:开始事务。
    setTransactionSuccessful:设置事务的成功标志。
    endTransaction:结束事务。执行本方法时,系统会判断之前是否调用了setTransactionSuccessful方法,如果之前已调用该方法就提交事务,如果没有调用该方法就回滚事务。
  3. 数据处理类,用于数据表层面的操作
    execSQL:执行拼好的的SQL控制语句。一般用于建表、删表、变更表结构。
    delete:删除符合条件的记录。
    update:更新符合条件的记录信息。
    insert:插入一条记录。
    query:执行查询操作,并返回结果集的游标。
    rawQuery:执行拼接好的SQL查询语句,并返回结果集的游标。

在实际开发中,经常用到的时查询语句,建议先写好查询操作的select语句,再调用rawQuery方法执行查询语句。

数据库帮助器SQLiteOpenHelper

由于SQLiteDatabase存在局限性,一不小心就会重复打开数据库,处理数据库的升级也不方便,因此Android提供了数据库帮助器SQLiteOpenHelper,帮助开发者合理使用SQLite。
SQLiteOpenHelper的具体使用步骤如下:

  1. 新建一个继承SQLiteOpenHelper的数据库操作类,按提示重写onCreateonUpgrade两个方法。其中,onCreate方法只在第一次打开数据库时执行,在此可以创建表结构;而onUpgrade方法在数据库版本升高时执行,再次可以根据新旧版本变更表结构。
  2. 为保证数据库的安全使用,需要封装几个必要方法,包括获取单例对象、打开数据库连接、关闭数据库,说明如下:
    获取单例对象:确保在App运行过程中数据库只会打开一次,避免重复打开引起错误。
    打开数据库连接:SQLite有锁机制,即读锁和写锁的处理,故而数据库连接也分两种,读连接可调用getReadableDatabase方法获得,写连接可调用getWritableDatabase方法获得。
    关闭数据库连接:数据库操作完毕,调用数据库实例的close方法关闭连接。
  3. 提供对表记录增加、删除、修改、查询的操作方法。
    能被SQLite直接使用的数据结构是ContentValue类,它类似于映射Map,也提供了putget方法存取键值。区别在于:ContentValue的键只能是字符串,不能是其他类型。ContentValue主要用于增加记录和更新记录,对应数据库的insertupdate方法。
    记录的查询操作用到了游标类Cursor,调用queryrawQuery方法返回的都是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,打开记录保存页面,依次录入信息并将两个用户信息的注册信息保存至数据库,如下两图所示:
在这里插入图片描述
在这里插入图片描述
再打开记录读取页面,从数据库读取用户注册信息显示在页面上,如下图:
在这里插入图片描述
上述演示页面主要用到了数据库记录的添加、查询和删除操作,对应的数据库帮助器关键代码如下,尤其关注里面的insertdeleteupdatequery方法:

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;
    }
}

优化记住密码功能

在之前实现的记住密码功能中,虽然使用了共享参数实现了记住密码功能,但是该方案只能记住一个用户的登录信息,并且手机号码跟密码没有对应关系,如果换个手机号码登录,前一个用户的登录信息就被覆盖了。真正的记住密码功能应当是这样的:先输入手机号码,然后根据手机号码匹配保存的密码,一个手机号码对应一个密码,从而实现具体手机号码的密码记忆功能。

  1. 声明一个数据库的帮助器对象,然后在活动页面的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(); // 暂停页面,则关闭数据库连接
}
  1. 登录成功时,如果用户勾选了“记住密码”复选框,就将手机号码及密码保存至数据库。也就是在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); // 往用户数据库添加登录成功的用户信息
}
  1. 再次打开登录页面,用户输入手机号后点击密码框时,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流FileOutputStreamFileInputStream。其中,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个步骤,详述如下:

  1. 检查App是否开启了指定权限
    权限检查需要调用ContextCompat.checkSelfPermission方法,该方法的第一个参数为活动实例,第二个为待检查的权限名称,例如存储卡的写权限名为android.permission.WRITE_EXTERNAL_STORAGE。注意checkSelfPermission方法的返回值,当它为PackageManager.PERMISSION_GRANTED时表示已经授权,斗则就是未获取授权。
  2. 请求系统弹窗,以便用户选择是否开启权限
    一旦发现某个权限尚未开启,就得弹窗提示用户手动开启,这个弹窗不是开发者自己写的提醒对话框,而是系统专门用于权限申请的对话框。调用ActivityCompat.requestPermissions方法,即可命令系统自动弹出权限申请窗口,该方法的第一个参数为活动实例,第二个参数为代申请的权限数组,第三个参数为本次操作的请求代码。
  3. 判断用户的权限选择结果
    然而上面第二步的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();
        }
    }
}

以上代码为了简化逻辑,将结果校验操作封装为PermissionUtilcheckGrant方法,该方法遍历授权结果数组,依次检查每个权限是否都是得到了授权了。详细的方法代码如下:

// 检查权限结果数组,返回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属性,看看其庐山真面目,具体步骤说明如下:

  1. 打开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">
  1. 在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项工作:

  1. 编写一个继承自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; // 在打开应用时对静态的应用实例赋值
    }
}
  1. 在活动页面代码中调用MainApplication的getInstance方法,获得它的是一个静态对象,再通过该对象访问MainApplication的公共变量和公共方法。
  2. 不要忘了在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,需要对其略加改造,具体改造步骤说明如下:

  1. 修改模块的build.gradle文件,往dependencies节点添加下面一行配置,表示导入指定版本的MultiDex库:
implementation("androidx.multidex:multidex:2.0.1")
  1. 在defaultConfig节点添加以下配置,表示开启多个dex功能:
multiDexEnabled = true // 避免方法数最多65536的问题
  1. 编写自定的Application,注意该Application类必须继承MultiDexApplication,代码如下:
public class MainApplication extends MultiDexApplication {
	// 此处省略内部方法与属性代码
}
  1. 打开AndroidManifest.xml,给application节点的android:name属性设置自定义的Application,代码如下:
android:name=".MainApplication"
  1. 重新编译App工程,之后运行的App就不会再出现方法数过多的问题了。

利用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.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_URIcontent://com.android.contacts/contacts
联系人电话号码ContactsContract.CommonDataKinds.Phone.CONTENT_URIcontent://com.android.contacts/data/phones
联系人邮箱ContactsContract.CommonDataKinds.Email.CONTENT_URIcontent://com.android.contacts/data/emails
短信ContentResolver.SMS_INBOX_CONTENT_URIcontent://sms/inbox
彩信ContentResolver.SMS_CONVERSATIONS_CONTENT_URIcontent://mms-sms/conversations
通话记录ContentResolver.Calls.CONTENT_URIcontent://call_log/calls

工程源码

点击源码链接即可下载工程源码。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/637795.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

ESP-IDF使用Button组件实现按键检测的功能

ESP32使用Button组件实现按键检测的功能 ESP-IDF 组件管理LED 组件简介测试button组件写在最后 ESP-IDF 组件管理 IDF 组件管理器工具用于下载 ESP-IDF CMake 项目的依赖项&#xff0c;该下载在 CMake 运行期间自动完成。IDF 组件管理器可以从自动从组件注册表 或 Git 仓库获取…

【数据结构与算法】之堆及其实现!

目录 1、堆的概念及结构 2、堆的实现 2.1 堆向下和向上调整算法 2.2 堆的创建 2.3 建堆时间复杂度 2.4 堆的插入 2.5 堆的删除 2.6 完整代码 3、完结散花 个人主页&#xff1a;秋风起&#xff0c;再归来~ 数据结构与算法 个人格言&#…

ACM实训第十七天

Is It A Tree? 问题 考试时应该做不出来&#xff0c;果断放弃 树是一种众所周知的数据结构&#xff0c;它要么是空的(null, void, nothing)&#xff0c;要么是一个或的集合满足以下属性的节点之间有向边连接的节点较多。 •只有一个节点&#xff0c;称为根节点&#xff0c;它…

【设计模式深度剖析】【2】【创建型】【工厂方法模式】

&#x1f448;️上一篇:单例模式 | 下一篇:抽象工厂模式&#x1f449;️ 目录 工厂方法模式概览工厂方法模式的定义英文原话直译 工厂方法模式的4个角色抽象工厂&#xff08;Creator&#xff09;角色具体工厂&#xff08;Concrete Creator&#xff09;角色抽象产品&#x…

Celery教程

一、什么是Celery 1.1、celery是什么 Celery是一个简单、灵活且可靠的&#xff0c;处理大量消息的分布式系统&#xff0c;专注于实时处理的异步任务队列&#xff0c;同时也支持任务调度。 Celery的架构由三部分组成&#xff0c;消息中间件&#xff08;message broker&#x…

从零开始学Vue3--环境搭建

1.搭建环境 下载nodejs nodejs下载地址 更新npm npm install -g npm 设置npm源&#xff0c;加快下载速度 npm config set registry https://registry.npmmirror.com 使用脚手架创建项目 npm create vuelatest 根据你的需要选择对应选项 进入新建的项目下载依赖 npm in…

大模型时代,掌握Event Stream技术提升Web响应速度

大模型时代,每天搜索都可能会用到一种或多种大模型,在大文本输出的时候,页面是一字一字,一段一段的慢慢输出出来的,这背后是如何实现的呢?我们以KIMI为例 先抓个请求 我们发现界面展示是一句话,但是接口返回的时候是一个字一个字的。 普通请求 多了Event Stream的处理 …

机器人非线性控制方法——线性化与解耦

机器人非线性控制方法是针对具有非线性特性的机器人系统所设计的一系列控制策略。其中&#xff0c;精确线性化控制和反演控制是两种重要的方法。 1. 非线性反馈控制 该控制律采用非线性反馈控制的方法&#xff0c;将控制输入 u 分解为两个部分&#xff1a; α(x): 这是一个与…

更新web文件40秒后生效

服务器web服务使用的是nginx。 经测试&#xff0c;上传文件后大约40秒后生效。 更新文件不立即生效。 网上资料说根nginx中sendfile选项有关。 在nginx配置文件中&#xff0c;http区域里将sedfile设置为off&#xff0c;重启nginx服务。 谷歌浏览器强制刷新一次&#xff0c;…

用ControlNet+Inpaint实现stable diffusion模特换衣

用ControlNetInpaint实现stable diffusion模特换衣 ControlNet 训练与架构详解ControlNet 的架构用于文本到图像扩散的 ControlNet训练过程Zero卷积层的作用解释 inpaintInpaint Anything 的重要性Inpaint Anything 的功能概述 在现代计算机视觉领域&#xff0c;稳定扩散&#…

【计算机视觉(3)】

基于Python的OpenCV基础入门——图形与文字的绘制 图形与文字的绘制&#xff1a;画线画矩形画圆画多边形加文字 图形与文字绘制的代码实现&#xff1a; 图形与文字的绘制&#xff1a; 画线 img cv2.line(img, pt1, pt2, color, thickness) 参数&#xff1a; img&#xff1a;…

Android 构建时:Manifest merger failed : Attribute application@name value

在AndroidStudio 构建时发现此问题&#xff1a; Manifest merger failed : Attribute applicationname value解决方案&#xff1a;在主Manifest中增加replace <applicationandroid:name".MyApp"android:allowBackup"false"tools:replace"android…

儿童卧室灯品牌该如何挑选?几款专业儿童卧室灯品牌分享

近视在儿童中愈发普遍&#xff0c;许多家长开始认识到&#xff0c;除了学业成绩之外&#xff0c;孩子的视力健康同样重要。毕竟&#xff0c;学业的落后可以逐渐弥补&#xff0c;而一旦孩子近视&#xff0c;眼镜便可能成为长期伴随。因此&#xff0c;专业的护眼台灯对于每个家庭…

工作站虚拟化:RTX A5000的图形工作站实现多用户独立运行Siemens NX 设计软件

一、背景 Siemens NX 是由西门子数字工业软件&#xff08;Siemens Digital Industries Software&#xff09;开发的一款先进的集成计算机辅助设计&#xff08;CAD&#xff09;、计算机辅助制造&#xff08;CAM&#xff09;和计算机辅助工程&#xff08;CAE&#xff09;软件。它…

Python代码实现代价函数

最小二乘法 最小二乘法是一种在统计学、数学、工程学和计算机科学等领域广泛使用的优化方法。 基本原理 最小二乘法的主要目的是找到一组模型参数&#xff0c;使得根据这些参数所预测的数据与实际观测数据之间的差异&#xff08;即残差&#xff09;的平方和最小。 数学表达…

【LeetCode刷题】三数之和、四数之和

【LeetCode刷题】Day 6 题目1&#xff1a;LCR 7.三数之和思路分析&#xff1a;思路1&#xff1a;排序暴力枚举set去重思路2&#xff1a;单调性双指针细节处理去重 题目2&#xff1a;18.四数之和思路分析&#xff1a;思路1&#xff1a;排序暴力枚举set去重思路2&#xff1a;单调…

浅析智能体开发(第二部分):智能体设计模式和软件架构

大语言模型&#xff08;LLM&#xff09;驱动的智能体&#xff08;AI Agent&#xff09;展现出许多传统软件所不具备的特征。不仅与传统软件的设计理念、方法、工具和技术栈有显著的差异&#xff0c;AI原生&#xff08;AI Native&#xff09;的智能体还融入了多种新概念和技术。…

SparkSQL入门

1、SparkSQL是什么&#xff1f; 结论&#xff1a;SparkSQL 是一个即支持 SQL 又支持命令式数据处理的工具 2、SparkSQL 的适用场景&#xff1f; 结论&#xff1a;SparkSQL 适用于处理结构化数据的场景&#xff0c;而Spark 的 RDD 主要用于处理 非结构化数据 和 半结构化数据 …

【撸源码】【ThreadPoolExecutor】线程池的工作原理深度解析——上篇

1. 前言 线程池这块&#xff0c;作为高频面试题&#xff0c;并且实际使用场景巨多&#xff0c;所以出了这篇文章&#xff0c;一块来研究一下线程池的实现原理&#xff0c;运行机制&#xff0c;从底层深挖&#xff0c;不再局限于面试题。 2. 线程池概览 2.1. 构造器 线程池总…

Leecode热题100---55:跳跃游戏(贪心算法)

题目&#xff1a; 给你一个非负整数数组 nums &#xff0c;你最初位于数组的 第一个下标 。数组中的每个元素代表你在该位置可以跳跃的最大长度。 判断你是否能够到达最后一个下标&#xff0c;如果可以&#xff0c;返回 true &#xff1b;否则&#xff0c;返回 false 。 贪心算…