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

news2024/11/15 17:18:25

键值对

此小节介绍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

工程源码

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

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

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Linux笔记之命令行JSON处理器jq

Linux笔记之命令行JSON处理器jq code review! 文章目录 Linux笔记之命令行JSON处理器jq1.安装2.jq 基本用法3.例程3.1. 示例JSON文件3.2. 读取特定字段3.3. 管道过滤器&#xff08;Pipe Filters&#xff09;3.4. 映射过滤器&#xff08;Map Filters&#xff09;3.5. 条件过滤…

python使用jsonpath来查找key并赋值

目录 一、引言 二、JsonPath简介 三、Python中的JsonPath库 四、使用JsonPath查找JSON Key 五、使用JsonPath赋值JSON Key 六、高级用法 七、结论 一、引言 在数据驱动的现代应用中&#xff0c;JSON&#xff08;JavaScript Object Notation&#xff09;已成为一种广泛使…

使用echarts配置中国地图

使用echarts配置中国地图 首先要下载地图的geoJSON数据&#xff0c;有两个方式下载&#xff0c;一种是去echarts的github资源文件里面&#xff0c;一种是去阿里云的datav网站。 1.1 echarts文档下载中国地图json数据 1.2 阿里云datav 新建项目&#xff0c;新建index.html,下…

HeyGen AI是什么?怎样使用HeyGen AI?

在数字时代&#xff0c;视频内容为王。无论是在社交媒体还是网站上&#xff0c;视频都以其独特的方式吸引着人们的眼球。然而&#xff0c;制作出专业水准的视频往往需要大量的时间和技术知识。HeyGen AI正是为了解决这一难题而诞生的。 HeyGen AI简介 HeyGen AI是一个创新的视…

做抖音小店需要清楚的5个核心点!

大家好&#xff0c;我是喷火龙。 不管你是在做抖音小店&#xff0c;还是在做其他的电商平台&#xff0c;如果已经做了一段时间了&#xff0c;但还是没有拿到什么结果&#xff0c;我所指的结果不是什么大结果&#xff0c;而是连温饱都解决不了&#xff0c;甚至说还在亏钱。 有…

ICLR 2024现场精彩回顾 机器学习大牛们的“踩高跷秀”嗨翻全场

会议之眼 快讯 2024年5月7-11日&#xff0c;第12届ICLR(International Conference on Learning Representations)即国际学习表征会议已经在奥地利维也纳展览中心圆满结束&#xff01;国际学习表征会议&#xff08;ICLR&#xff09;作为机器学习领域的顶级会议之一&#xff0c;…

Threejs路径规划_基于A*算法案例V2

路径规划算法中有两种算法使用最普遍&#xff0c;第一个是Dijkstr算法&#xff0c;第二个是A*算法&#xff0c;两个算法各有千秋&#xff0c;Dijkstra算法可以保证最优解&#xff0c;但是复杂度较高&#xff0c;尤其当点数量较多时&#xff0c;A*算法是一种启发式搜索算法&…

Offline RL : Beyond Reward: Offline Preference-guided Policy Optimization

ICML 2023 paper code preference based offline RL&#xff0c;基于HIM&#xff0c;不依靠额外学习奖励函数 Intro 本研究聚焦于离线偏好引导的强化学习&#xff08;Offline Preference-based Reinforcement Learning, PbRL&#xff09;&#xff0c;这是传统强化学习&#x…

设计模式13——桥接模式

写文章的初心主要是用来帮助自己快速的回忆这个模式该怎么用&#xff0c;主要是下面的UML图可以起到大作用&#xff0c;在你学习过一遍以后可能会遗忘&#xff0c;忘记了不要紧&#xff0c;只要看一眼UML图就能想起来了。同时也请大家多多指教。 桥接模式&#xff08;Bridge&a…

Hsql每日一题 | day02

前言 就一直向前走吧&#xff0c;沿途的花终将绽放~ 题目&#xff1a;主播同时在线人数问题 如下为某直播平台主播开播及关播时间&#xff0c;根据该数据计算出平台最高峰同时在线的主播人数。 id stt edt 1001,2021-06-14 12:12:12,2021-06-14 18:1…

makefile 编写规则

1.概念 1.1 什么是makefile Makefile 是一种文本文件&#xff0c;用于描述软件项目的构建规则和依赖关系&#xff0c;通常用于自动化软件构建过程。它包含了一系列规则和指令&#xff0c;告诉构建系统如何编译和链接源代码文件以生成最终的可执行文件、库文件或者其他目标文件…

【杂七杂八】Huawei Gt runner手表系统降级

文章目录 Step1&#xff1a;下载安装修改版华为运动与健康Step2&#xff1a;在APP里进行配置Step3&#xff1a;更新固件(时间会很长) 目前在使用用鸿蒙4 111版本的手表系统&#xff0c;但是感觉睡眠检测和运动心率检测一言难尽&#xff0c;于是想到是否能回退到以前的版本&…

NFT Insider #131:Mocaverse NFT市值破3.5万ETH,The Sandbox 参加NFCsummit

引言&#xff1a;NFT Insider由NFT收藏组织WHALE Members&#xff08;https://twitter.com/WHALEMembers&#xff09;、BeepCrypto &#xff08;https://twitter.com/beep_crypto&#xff09;联合出品&#xff0c;浓缩每周NFT新闻&#xff0c;为大家带来关于NFT最全面、最新鲜、…

element-ui手机区号+手机号

需求场景 项目开发中对方要求手机号带上全球区号 需求分析 项目使用的是若依前端框架&#xff0c;element-ui的框架。尝试使用已经网上的组件vue-country-intl等发现不怎么适配element-ui的样式等。这还不是关键的&#xff0c;关键的是弹窗中使用这些组件发现区号的下拉展示框…

OpenFeign快速入门 替代RestTemplate

1.引入依赖 <!--openFeign--><dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency><!--负载均衡器--><dependency><groupId>org.spr…

本特利330103-03-09-10-02-00 PLC模块技术分析与应用探讨

本特利330103-03-09-10-02-00 PLC模块技术分析与应用探讨 一、引言 在工业自动化领域中&#xff0c;可编程逻辑控制器&#xff08;PLC&#xff09;作为核心控制设备&#xff0c;其性能的稳定性和可靠性直接关系到整个生产线的运行效率。本特利&#xff08;Bentley&#xff09;…

2.行为参数的演变过程

2.行为参数的演变过程 ​ 行为参数化是软件开发模式&#xff0c;可以处理频繁变更的需求。它让你把一个代码块准备好但不执行&#xff0c;以后可以被其他部分调用&#xff0c;也可以作为参数传递给另一个方法&#xff0c;推迟执行。这样&#xff0c;方法的行为就基于参数化的代…

一文深度剖析 ColBERT

近年来&#xff0c;向量搜索领域经历了爆炸性增长&#xff0c;尤其是在大型语言模型&#xff08;LLMs&#xff09;问世后。学术界开始重点关注如何通过扩展训练数据、采用先进的训练方法和新的架构等方法来增强 embedding 向量模型。 在之前的文章中&#xff0c;我们已经深入探…

领券拿外卖返利红包,最低0元吃外卖

小蚕荟是利用本地资源和自媒体优势构建的“本地生活服务”平台&#xff0c;总部位于杭州&#xff0c;旨在为用户提供热门的吃喝玩乐本地生活服务类产品。布局已覆盖杭州、南京、上海等一二线城市。 小蚕荟是一款专为用户吃外卖省钱的生活工具&#xff0c;单单可返利15元起&…

使用霍尔效应传感或磁场传感技术的应用

随着支持技术的增强&#xff0c;使用霍尔效应传感或磁场传感技术的应用目前已变得有效。本技术文档介绍了霍尔效应技术&#xff0c;并对应用进行了回顾&#xff0c;特别是区分霍尔传感器 IC 的主要类型以及它们可以支持的各种传感行为。此外&#xff0c;它还探讨了一些使能技术…