目录
[一、ContentProvider 的作用](#一、ContentProvider 的作用)
[图中两个 App:](#图中两个 App:)
[🔗 工作流程说明(图解):](#🔗 工作流程说明(图解):)
[1. 创建 SQLite 数据库辅助类(UserDBHelper)](#1. 创建 SQLite 数据库辅助类(UserDBHelper))
[2. 创建 ContentProvider 子类(UserInfoProvider)](#2. 创建 ContentProvider 子类(UserInfoProvider))
[删除 delete()](#删除 delete())
[插入 insert()](#插入 insert())
[查询 query()](#查询 query())
[1. AUTHORITIES](#1. AUTHORITIES)
[2. CONTENT_URI](#2. CONTENT_URI)
[3. 字段名常量(PHONE, PSW 等)](#3. 字段名常量(PHONE, PSW 等))
[4. ID 与 BaseColumns](#4. ID 与 BaseColumns)
[1. @SuppressLint("Range")](#1. @SuppressLint("Range"))
[2. ContentValues 的使用](#2. ContentValues 的使用)
[3. ContentResolver.insert() 插入数据](#3. ContentResolver.insert() 插入数据)
[4. ContentUris.withAppendedId() 构建带 ID 的 URI](#4. ContentUris.withAppendedId() 构建带 ID 的 URI)
[5. ContentResolver.delete() 删除数据](#5. ContentResolver.delete() 删除数据)
[6. ContentResolver.query() 查询数据](#6. ContentResolver.query() 查询数据)
[7. Cursor 获取数据](#7. Cursor 获取数据)
[PermissionUtil 工具类](#PermissionUtil 工具类)
[PermissionLazyActivity 示例](#PermissionLazyActivity 示例)
[👉 1. 用户点击按钮](#👉 1. 用户点击按钮)
[👉 2. 检查权限是否已授权](#👉 2. 检查权限是否已授权)
[👉 3. 系统弹窗授权后进入回调](#👉 3. 系统弹窗授权后进入回调)
[👉 4. 判断用户授权结果](#👉 4. 判断用户授权结果)
[与 Lazy 模式对比](#与 Lazy 模式对比)
[与 Lazy 模式的实现差异点(Eager 模式)如下:](#与 Lazy 模式的实现差异点(Eager 模式)如下:)
[onCreate() 中立即申请所有权限(Eager 模式的核心特征)](#onCreate() 中立即申请所有权限(Eager 模式的核心特征))
[重点关注 REQUEST_CODE_ALL 的权限回调逻辑](#重点关注 REQUEST_CODE_ALL 的权限回调逻辑)
[什么是 RawContacts 和 Data 表](#什么是 RawContacts 和 Data 表)
[🔧 插入步骤说明](#🔧 插入步骤说明)
[✨ 优势](#✨ 优势)
[1️⃣ ContentProviderOperation 是什么?](#1️⃣ ContentProviderOperation 是什么?)
[2️⃣ 什么是 withValueBackReference()?](#2️⃣ 什么是 withValueBackReference()?)
[什么是 ContentObserver?](#什么是 ContentObserver?)
[监听的 URI 是什么?](#监听的 URI 是什么?)
[为什么设置 true:](#为什么设置 true:)
[重写 onChange() 方法:](#重写 onChange() 方法:)
[忽略无效 uri 的判断逻辑:](#忽略无效 uri 的判断逻辑:)
[onChange 中的处理逻辑](#onChange 中的处理逻辑)
[监听 + 通知流程](#监听 + 通知流程)
[当 ContentProvider 插入或更新数据,并调用:](#当 ContentProvider 插入或更新数据,并调用:)
[1. 打开系统图库选择图片](#1. 打开系统图库选择图片)
[2. 需要的权限声明](#2. 需要的权限声明)
[📌 Manifest 中声明(运行前准备):](#📌 Manifest 中声明(运行前准备):)
[为什么要用 FileProvider?](#为什么要用 FileProvider?)
[二、Manifest 权限与配置准备](#二、Manifest 权限与配置准备)
[1. 添加必需权限](#1. 添加必需权限)
[2. FileProvider 注册](#2. FileProvider 注册)
[4. 配置 strings.xml](#4. 配置 strings.xml)
[5. 配置 file_paths.xml](#5. 配置 file_paths.xml)
[三、使用 MediaStore 加载图片的关键](#三、使用 MediaStore 加载图片的关键)
[1. MediaStore 是什么?](#1. MediaStore 是什么?)
[2. 为什么需要手动扫描文件夹?](#2. 为什么需要手动扫描文件夹?)
[3. 查询图片的常用 URI 与字段](#3. 查询图片的常用 URI 与字段)
[4. 从系统媒体库中加载符合条件的图片列表,并保存在 mImageList 中备用代码](#4. 从系统媒体库中加载符合条件的图片列表,并保存在 mImageList 中备用代码)
[① 创建 URI](#① 创建 URI)
[② 使用 FileProvider 转换为 content:// URI](#② 使用 FileProvider 转换为 content:// URI)
[③ 构建 Intent](#③ 构建 Intent)
[④ 添加彩信内容](#④ 添加彩信内容)
[⑤ 启动 Intent](#⑤ 启动 Intent)
[一、为什么要使用 FileProvider 安装 APK?](#一、为什么要使用 FileProvider 安装 APK?)
[1️⃣ 权限处理(分版本)](#1️⃣ 权限处理(分版本))
[Android 11(API 30)及以上:](#Android 11(API 30)及以上:)
[Android 10 及以下:](#Android 10 及以下:)
[2️⃣ APK 路径的构造](#2️⃣ APK 路径的构造)
[3️⃣ 校验 APK 文件是否有效](#3️⃣ 校验 APK 文件是否有效)
[4️⃣ 获取 FileProvider 的 content:// URI](#4️⃣ 获取 FileProvider 的 content:// URI)
[5️⃣ 构建安装 Intent 并启动安装器](#5️⃣ 构建安装 Intent 并启动安装器)
[一、什么是 FileProvider?](#一、什么是 FileProvider?)
[二、FileProvider 解决了什么问题?](#二、FileProvider 解决了什么问题?)
[👉 把 File(本地路径)转换成内容 URI(content:// 格式)](#👉 把 File(本地路径)转换成内容 URI(content:// 格式))
在应用之间共享数据
通过ContentProvided封装数据
ContentProvider 是 Android 中四大组件之一(另外三个是 Activity、Service 和 BroadcastReceiver),它的主要作用是 在不同应用之间共享数据。
一、ContentProvider 的作用
-
实现跨应用数据共享:让一个应用的私有数据可以在授权的情况下被其他应用访问。
-
统一数据访问接口:使用 URI 统一管理不同类型的数据(如联系人、媒体、应用私有数据库等)。

图中含义详解:
图中两个 App:
-
Server App :数据的"拥有者",它里面有数据库(SQLite)和
ContentProvider。 -
Client App:数据的"使用者",它想把输入的数据存进 Server App 的数据库,或者读取已有数据。
🔗 工作流程说明(图解):
-
Client App 输入数据(如输入姓名、手机号等);
-
它通过
ContentResolver(Android 提供的客户端访问接口)来调用 Server App 的ContentProvider; -
ContentProvider 会将这些数据传给 Server App 内部的 SQLite 数据库;
-
数据就这样 安全地从 Client App 写入到了 Server App 的数据库中。
这个过程是 跨应用、跨进程通信
Server端代码编写:
1. 创建 SQLite 数据库辅助类(UserDBHelper)
java
public class UserDBHelper extends SQLiteOpenHelper {
private static final String DB_NAME = "user.db";
private static final int DB_VERSION = 1;
public static final String TABLE_NAME = "user";
private static UserDBHelper mHelper = null;
private UserDBHelper(Context context) {
super(context,DB_NAME,null,DB_VERSION);
}
// 利用单列模式获取数据库帮助器的唯一实例
public static UserDBHelper getInstance(Context context){
if(mHelper == null) mHelper = new UserDBHelper(context);
return mHelper;
}
// 创建数据库,执行建表语句
@Override
public void onCreate(SQLiteDatabase db) {
String sql = "CREATE TABLE "+TABLE_NAME+" (id INTEGER PRIMARY KEY AUTOINCREMENT, phone varchar, psw varchar)";
db.execSQL(sql);
}
// 版本号更新的时候执行
@Override
public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) {
// 修改版本之后,可以在这里面对表结构进行新增字段
}
}
2. 创建 ContentProvider 子类(UserInfoProvider)
用于对用户信息表进行 增、删、查(未实现更新) 的封装操作,并提供统一的 URI 接口供其他应用或组件调用。
类声明和成员变量
java
public class UserInfoProvider extends ContentProvider {
private UserDBHelper dbHelper;
-
继承自
ContentProvider,是 Android 四大组件之一,专门用于 跨应用共享数据。 -
dbHelper是一个操作 SQLite 数据库的帮助类(通常继承自SQLiteOpenHelper)。
初始化数据库
java
@Override
public boolean onCreate() {
dbHelper = UserDBHelper.getInstance(getContext());
return true;
}
-
onCreate()是ContentProvider的生命周期回调,在 Provider 第一次被访问时自动执行。 -
使用单例模式创建
UserDBHelper实例,避免多次打开数据库。
UriMatcher:路径匹配器
java
private static final UriMatcher URI_MATCHER = new UriMatcher(UriMatcher.NO_MATCH);
private static final int USERS = 1; // 多行(例如查询全部)
private static final int USER = 2; // 单行(例如 id=3)
static {
URI_MATCHER.addURI(UserInfoContent.AUTHORITIES,"/user",USERS);
URI_MATCHER.addURI(UserInfoContent.AUTHORITIES,"/user/#",USER);
}
-
UriMatcher是 Android 提供的工具类,用于根据 URI 进行规则匹配。 -
AUTHORITY是定义的提供者标识符,例如:com.example.chapter04_server.provider.UserInfoProvider -
/user代表查询所有数据; -
/user/#代表指定 id 的单条数据(# 是数字通配符);
🔗 举例 URI:
bash
content://com.example.chapter04_server.provider.UserInfoProvider/user // 匹配 USERS
content://com.example.chapter04_server.provider.UserInfoProvider/user/3 // 匹配 USER
删除 delete()
java
@Override
public int delete(Uri uri, String selection, String[] selectionArgs) {
int count = 0;
switch (URI_MATCHER.match(uri)) {
case USERS:
SQLiteDatabase db1 = dbHelper.getWritableDatabase();
count = db1.delete(UserDBHelper.TABLE_NAME, selection, selectionArgs);
db1.close();
break;
case USER:
String id = uri.getLastPathSegment(); // 取出最后的 id
SQLiteDatabase db2 = dbHelper.getWritableDatabase();
db2.delete(UserDBHelper.TABLE_NAME, "id=?", new String[]{id});
break;
}
return count;
}
-
根据 URI 是删除多条(
USERS)还是单条(USER)记录来区分操作。 -
getLastPathSegment()取 URI 的最后部分(即 id 值)。
插入 insert()
java
@Override
public Uri insert(Uri uri, ContentValues values) {
Log.d("ning", "UserInfoProvider的insert");
if (URI_MATCHER.match(uri) == USERS) {
SQLiteDatabase db = dbHelper.getWritableDatabase();
long rowId = db.insert(UserDBHelper.TABLE_NAME, null, values);
if (rowId > 0) {
Uri insertedUri = ContentUris.withAppendedId(uri, rowId);
return insertedUri;
}
return null; // 插入失败
}
return null;
}
-
当 URI 匹配
USER时插入一条记录。 -
ContentValues是键值对形式的数据对象,用于传入字段和值。
🔍 注意:
- 你这里只允许插入单条数据(
/user/#),通常插入操作应该也允许/user类型更合理。
查询 query()
java
@Override
public Cursor query(Uri uri, String[] projection, String selection,
String[] selectionArgs, String sortOrder) {
Log.d("ning","UserInfoProvider的query");
if (URI_MATCHER.match(uri) == USERS){
SQLiteDatabase db = dbHelper.getReadableDatabase();
return db.query(UserDBHelper.TABLE_NAME, projection, selection, selectionArgs, null, null, null);
}
return null;
}
- 实现了多行查询
USERS(即/user),返回 Cursor 游标对象。
自定义的常量类
封装和
ContentProvider打交道时用到的常量,避免写死字符串,方便统一管理和维护。
java
public class UserInfoContent implements BaseColumns {
// 1. 定义 Provider 的唯一 authority
public static final String AUTHORITIES = "com.example.chapter04_server.provider.UserInfoProvider";
// 2. 定义访问整个 user 表的 URI
public static final Uri CONTENT_URI = Uri.parse("content://" + AUTHORITIES + "/user");
// 3. 定义字段名常量
public static final String PHONE = "phone";
public static final String PSW = "psw";
// 4. 定义主键字段名(如果使用了 BaseColumns,这个字段通常是 _id)
public static final String ID = "id";
}
1. AUTHORITIES
-
与 AndroidManifest.xml 中注册的
<provider android:authorities="..."/>保持一致。 -
是 ContentProvider 唯一的标识符。
2. CONTENT_URI
-
是你访问数据的入口 URI(可类比于 RESTful 的 API 路径)。
-
/user是表名或者你想要暴露的数据路径。 -
可以用来进行增删改查,比如:getContentResolver().insert(UserInfoContent.CONTENT_URI, values);
3. 字段名常量(PHONE, PSW 等)
-
用于数据库字段名的统一引用。
-
可避免硬编码
"phone"、"psw"等字符串,减少出错率,便于自动补全。
4. ID 与 BaseColumns
-
BaseColumns是 Android 提供的接口,会自动为你提供两个字段名:-
_ID(等价于" _id",数据库主键) -
_COUNT(记录总数,可选)
-
所以你如果继承了 BaseColumns,建议你的数据库表主键字段就用 _id,否则系统组件如 CursorAdapter、Loader 等无法正常识别。
通过ContentResolver访问数据
ContentResolver 是 Android 提供的统一接口,用于在应用之间访问数据,它作为桥梁帮助我们访问由 ContentProvider 提供的数据。
你可以通过 ContentResolver 实现跨应用的数据操作,如联系人、短信、媒体、第三方App数据等。
| 概念 | 类比角色 | 说明 |
|---|---|---|
| ContentProvider | 数据服务窗口 | 是数据的"管理者",对外提供统一的数据访问接口(CURD 操作) |
| ContentResolver | 顾客/访问者 | 是"客户端",通过它来访问 ContentProvider 提供的数据 |
常用方法
| 方法 | 功能 | 返回值 |
|---|---|---|
insert(Uri, ContentValues) |
插入数据 | Uri(插入项的 URI) |
delete(Uri, String, String[]) |
删除数据 | int(删除的行数) |
update(Uri, ContentValues, String, String[]) |
更新数据 | int(更新的行数) |
query(Uri, String[], String, String[], String) |
查询数据 | Curs |
示例
java
@SuppressLint("Range") // 不用提示
@Override
public void onClick(View v) {
if(v.getId() == R.id.btn_add){
ContentValues values = new ContentValues();
values.put(UserInfoContent.PHONE,et_phone.getText().toString());
values.put(UserInfoContent.PSW,et_password.getText().toString());
getContentResolver().insert(UserInfoContent.CONTENT_URI,values);
Toast.makeText(this,"新增用户成功",Toast.LENGTH_SHORT).show();
}else if(v.getId() == R.id.btn_delete){
// 删除单行
Uri uri = ContentUris.withAppendedId(UserInfoContent.CONTENT_URI, 2);
int count =getContentResolver().delete(uri,"phone=?",new String[]{et_phone.getText().toString()});
// 删除多行
// int count =getContentResolver().delete(UserInfoContent.CONTENT_URI,"phone=?",new String[]{et_phone.getText().toString()});
if(count>0){
Toast.makeText(this,"删除用户成功",Toast.LENGTH_SHORT).show();
}
}else if(v.getId() == R.id.btn_update){
Toast.makeText(this,"修改用户成功",Toast.LENGTH_SHORT).show();
} else if(v.getId() == R.id.btn_select){
Cursor cursor = getContentResolver().query(UserInfoContent.CONTENT_URI, null, null, null);
if(cursor != null){
while(cursor.moveToNext()){
User user = new User();
// user.setId(cursor.getInt(cursor.getColumnIndex(UserInfoContent._ID)));
user.setId(cursor.getInt(cursor.getColumnIndex(UserInfoContent.ID)));
user.setPhone(cursor.getString(cursor.getColumnIndex("phone")));
user.setPsw(cursor.getString(cursor.getColumnIndex("psw")));
Log.d("ning",user.toString());
}
}
cursor.close();
Toast.makeText(this,"查询所有用户成功",Toast.LENGTH_SHORT).show();
}
}
1. @SuppressLint("Range")
作用:
告诉编译器"忽略"关于 Cursor.getColumnIndex() 使用字符串列名带来的潜在风险警告(比如列名可能拼写错误等)。
背景:
从 Android API 30 开始,Google 推荐使用 getColumnIndexOrThrow() 或强类型方式,getColumnIndex("xxx") 会提示警告。
2. ContentValues 的使用
作用:
构造一个键值对的对象,用于向 ContentProvider 插入或更新数据。
java
ContentValues values = new ContentValues();
values.put("phone", "123456789");
values.put("psw", "abc123");
类似于 Map,专用于数据库操作。
3. ContentResolver.insert() 插入数据
java
getContentResolver().insert(UserInfoContent.CONTENT_URI, values);
作用:
通过系统的 ContentResolver 调用目标 ContentProvider 的 insert() 方法,把一行数据插入数据库。
4. ContentUris.withAppendedId() 构建带 ID 的 URI
java
Uri uri = ContentUris.withAppendedId(UserInfoContent.CONTENT_URI, 2);
作用:
给基础 URI 添加一个路径段(如 ID),生成一个具体资源 URI,例如:
bash
content://com.example.provider/user/2
用于针对特定记录操作(如删除单行)。
5. ContentResolver.delete() 删除数据
java
getContentResolver().delete(uri, "phone=?", new String[]{...});
-
uri: 要操作的资源(数据表或单条数据) -
"phone=?": where 子句 -
new String[]{...}: 替代问号的参数
6. ContentResolver.query() 查询数据
-
URI:查询的目标资源
-
projection:查询哪些列(null 表示全部)
-
selection / selectionArgs:筛选条件
-
sortOrder:排序规则
返回的是 Cursor 结果集。
7. Cursor 获取数据
-
Cursor是一张"游标表",指向数据库的查询结果。 -
moveToNext():移动到下一行。 -
getColumnIndex(...):获取列索引。 -
getInt()/getString():读取指定列的数据。
使用内容组件获取通讯消息
运行时动态申请权限
Lazy模式
Lazy 的思想:不是一开始就加载或执行所有功能,而是"用到再申请 / 用到再处理"。
在权限申请中,这种懒加载体现在:
-
App 启动时不立刻申请所有权限;
-
而是当用户点击"读写通讯录"或"收发短信"等按钮时,才动态去检查/申请对应权限。
动态权限申请的三步骤
| 步骤 | 方法 | 作用 |
|---|---|---|
| 第一步 | ContextCompat.checkSelfPermission |
检查权限是否已授权 |
| 第二步 | ActivityCompat.requestPermissions |
请求权限(系统弹窗) |
| 第三步 | onRequestPermissionsResult |
获取用户的授权结果 |
PermissionUtil 工具类
这是一个用于封装权限判断与请求的实用工具类。
java
public class PermissionUtil {
// 检查多个权限,返回 true 表示权限都已授权,false 表示需请求权限
public static boolean checkPermission(Activity atc, String[] permissions, int requestCode) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
int check = PackageManager.PERMISSION_GRANTED;
for (String permission : permissions) {
check = ContextCompat.checkSelfPermission(atc, permission);
if (check != PackageManager.PERMISSION_GRANTED) break;
}
if (check != PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(atc, permissions, requestCode);
return false;
}
}
return true;
}
// 判断权限请求结果是否全部授权
public static boolean checkGrant(int[] grantResults) {
if (grantResults != null) {
for (int result : grantResults) {
if (result != PackageManager.PERMISSION_GRANTED) {
return false;
}
}
return true;
}
return false;
}
}
| 方法名 | 功能 | 何时调用 |
|---|---|---|
checkPermission |
检查权限并请求 | 在点击按钮时调用,发起权限请求 |
checkGrant |
判断权限结果 | 在回调 onRequestPermissionsResult 中调用 |
checkPermission(...)
功能:
检查一组权限是否已全部授权,如果有权限未授权,则自动弹出系统授权请求。
逻辑简述:
-
判断系统版本是否为 Android 6.0+;
-
遍历权限数组,查看是否全部已授权;
-
如果有未授权的权限,调用
requestPermissions弹出授权对话框; -
返回值:
-
true:权限已全部授予; -
false:权限未授予,已发起授权请求。
-
checkGrant(...)
功能:
在权限申请回调中,判断用户是否授权成功。
逻辑简述:
-
遍历
grantResults数组; -
只要有一个结果不为
PERMISSION_GRANTED,就表示授权失败; -
返回值:
-
true:用户全部授权; -
false:用户至少拒绝了一个权限或结果为空。
-
PermissionLazyActivity 示例
点击按钮时才申请权限的典型 Activity。
权限列表
java
// 通讯录读写权限
private static final String[] PERMISSIONS_CONTACTS = {
Manifest.permission.READ_CONTACTS,
Manifest.permission.WRITE_CONTACTS
};
// 短信权限
private static final String[] PERMISSIONS_SMS = {
Manifest.permission.SEND_SMS,
Manifest.permission.READ_SMS
};
核心逻辑实现
java
public class PermissionLazyActivity extends AppCompatActivity implements View.OnClickListener {
private static final int REQUEST_CODE_CONTACTS = 1;
private static final int REQUEST_CODE_SMS = 2;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_permission_lazy);
findViewById(R.id.btn_contact).setOnClickListener(this);
findViewById(R.id.btn_sms).setOnClickListener(this);
}
@Override
public void onClick(View v) {
if (v.getId() == R.id.btn_contact) {
PermissionUtil.checkPermission(this, PERMISSIONS_CONTACTS, REQUEST_CODE_CONTACTS);
} else if (v.getId() == R.id.btn_sms) {
PermissionUtil.checkPermission(this, PERMISSIONS_SMS, REQUEST_CODE_SMS);
}
}
// 用户是否同意
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
switch (requestCode){
case REQUEST_CODE_CONTACTS:
if(PermissionUtil.checkGrant(grantResults)){
Toast.makeText(this,"获取通讯录读写权限成功", Toast.LENGTH_SHORT).show();
Log.d("ning","获取通讯录读写权限成功");
}else{
Log.d("ning","获取通讯录读写权限失败");
Toast.makeText(this,"获取通讯录读写权限失败", Toast.LENGTH_SHORT).show();
jumpToSettings();
}
break;
case REQUEST_CODE_SMS:
if(PermissionUtil.checkGrant(grantResults)){
Log.d("ning","获取短信读写权限成功");
}else {
Toast.makeText(this,"获取短信读写权限失败", Toast.LENGTH_SHORT).show();
Log.d("ning","获取短信读写权限失败");
jumpToSettings();
}
break;
}
}
// 跳转系统设置页引导用户手动开启权限
private void jumpToSettings() {
Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
intent.setData(Uri.fromParts("package", getPackageName(), null));
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(intent);
}
}
👉 1. 用户点击按钮
根据按钮的 ID,判断用户点击的是哪个功能(通讯录 or 短信)。
👉 2. 检查权限是否已授权
使用工具类 PermissionUtil.checkPermission(...) 检查对应权限组是否全部授权:
-
若已授权:返回
true,可直接执行功能; -
若未授权:自动弹出权限请求框,并返回
false。

👉 3. 系统弹窗授权后进入回调
当用户在弹窗中做出选择后,系统会调用:
java
onRequestPermissionsResult(requestCode, permissions, grantResults)
👉 4. 判断用户授权结果
在回调中使用 PermissionUtil.checkGrant(...) 判断授权结果数组:
-
若用户授权成功:输出成功日志或执行功能;
-
若用户拒绝授权:提示失败,并跳转到系统设置页,引导手动授权。
注意:还需要在清单文件当中进行权限声明
XML
<!-- 明确声明短信功能是可选的,防止权限请求被系统忽略 -->
<uses-feature android:name="android.hardware.telephony" android:required="false"/>
<uses-permission android:name="android.permission.READ_CONTACTS"/>
<uses-permission android:name="android.permission.WRITE_CONTACTS"/>
<uses-permission android:name="android.permission.READ_SMS"/>
<uses-permission android:name="android.permission.SEND_SMS"/>
Eager模式
在应用启动阶段(如 SplashActivity、MainActivity 的 onCreate() 中),提前一次性申请应用可能使用到的所有危险权限,而不是等用户操作到对应功能时再请求。
与 Lazy 模式对比
| 对比项 | Eager 模式 | Lazy 模式 |
|---|---|---|
| 权限申请时机 | 应用启动时立即申请所有可能用到的权限 | 用户点击功能时再按需申请 |
| 用户体验 | 差,权限弹窗密集且无上下文 | 好,用户知道为何请求权限 |
| 审核友好度 | 低,易被拒 | 高,符合"权限最小化"原则 |
| 实现复杂度 | 简单 | 稍高,需要在多个入口处理权限 |
与 Lazy 模式的实现差异点(Eager 模式)如下:
java
// 读写权限
private static final String[] PERMISSIONS = new String[]{
Manifest.permission.READ_CONTACTS,
Manifest.permission.WRITE_CONTACTS,
Manifest.permission.SEND_SMS,
Manifest.permission.READ_SMS
};
private static final int REQUEST_CODE_ALL=1;
private static final int REQUEST_CODE_CONTACTS=2;
private static final int REQUEST_CODE_SMS=3;
PERMISSIONS 把四个"危险权限"一次性准备好,供:
Eager 模式一次性请求时使用(如checkPermission(this, PERMISSIONS, REQUEST_CODE_ALL))
REQUEST_CODE_XXX 常量:用于标记是哪一类权限请求
权限请求时我们需要一个 requestCode,以便在 onRequestPermissionsResult() 中判断是哪个请求返回:
| 常量名 | 值 | 用途 |
|---|---|---|
REQUEST_CODE_ALL |
1 | 表示"所有权限一起申请"的请求(Eager模式) |
REQUEST_CODE_CONTACTS |
2 | 表示"仅申请通讯录权限"的请求(Lazy模式) |
REQUEST_CODE_SMS |
3 | 表示"仅申请短信权限"的请求(Lazy模式) |
onCreate() 中立即申请所有权限(Eager 模式的核心特征)
java
// Lazy 模式中不会有这段代码
// Eager 模式中添加:
PermissionUtil.checkPermission(this, PERMISSIONS, REQUEST_CODE_ALL);
-
📌 Lazy 模式中,仅在点击按钮后才调用
checkPermission -
✅ Eager 模式中在
onCreate()阶段就统一申请
点击按钮仍然可以保留单独请求(可选)
- 在 Eager 模式中,点击按钮再申请权限用于容错:
java
// 若保留以下内容,则是"Eager + Lazy混合式",但 Eager 已抢先申请
PermissionUtil.checkPermission(this, new String[]{PERMISSIONS[0], PERMISSIONS[1]}, REQUEST_CODE_CONTACTS);
重点关注 REQUEST_CODE_ALL 的权限回调逻辑
onRequestPermissionsResult()中对REQUEST_CODE_ALL的处理逻辑要详尽,因为所有权限一次性集中返回:
java
case REQUEST_CODE_ALL:
if (PermissionUtil.checkGrant(grantResults)) {
Log.d("ning", "所有权限获取成功");
} else {
// 部分权限获取失败
for (int i = 0; i < grantResults.length; i++) {
if (grantResults[i] != PackageManager.PERMISSION_GRANTED) {
// 根据索引 i 在 permissions 数组中判断失败的权限
switch (permissions[i]) {
case Manifest.permission.READ_CONTACTS:
case Manifest.permission.WRITE_CONTACTS:
Log.d("ning", "获取通讯录读写权限失败");
jumpToSettings();
break;
case Manifest.permission.SEND_SMS:
case Manifest.permission.READ_SMS:
Log.d("ning", "获取短信读写权限失败");
jumpToSettings();
break;
}
}
}
}
break;
回调参数说明
-
permissions[]:你申请的每一个权限名称(字符串数组) -
grantResults[]:和permissions[]一一对应,表示每个权限的授予结果
| permissions[i] | grantResults[i] |
|---|---|
| Manifest.permission.READ_CONTACTS | PERMISSION_GRANTED 或 PERMISSION_DENIED |
| Manifest.permission.SEND_SMS | PERMISSION_GRANTED 或 PERMISSION_DENIED |
使用
for (int i)遍历grantResults,结合permissions[i]的值,可以逐个判断是哪项权限失败了 ,从而做出分类处理或提示用户去设置页打开权限。
遍历所有权限结果:
java
for (int i = 0; i < grantResults.length; i++)
判断当前第 i 个权限是否被拒绝:
java
if (grantResults[i] != PackageManager.PERMISSION_GRANTED)
查找是哪一个权限失败:
-
通过
permissions[i]取出当前这项权限的字符串值 -
使用
switch判断具体是哪个权限失败 -
再通过日志/跳转设置页/弹窗等方式提示用户
利用ContResolver读写联系人
联系人是如何存储的?
联系人数据库主要涉及两个 URI:
-
ContactsContract.RawContacts.CONTENT_URI:插入新联系人用。 -
ContactsContract.Data.CONTENT_URI:写入联系人详细数据(姓名、电话、邮箱等)。
添加联系人
步骤概览:
-
插入一条 raw contact。
-
根据这条 raw contact 的 ID 添加姓名。
-
添加电话、邮箱等信息。
添加联系人两种方式
-
方式一:多次调用 ContentResolver 插入数据(非事务)
-
方式二:批处理操作 applyBatch(事务方式,推荐)
URI 是关键桥梁,例如:
-
插入联系人用
ContactsContract.RawContacts.CONTENT_URI -
写入数据用
ContactsContract.Data.CONTENT_URI
什么是 RawContacts 和 Data 表
| 表名 | 作用 |
|---|---|
| RawContacts | 存放联系人主体信息(ID) |
| Data | 存放具体字段,如姓名、电话、邮箱等,每一条都有 MIME 类型指明字段类型 |
Data 表是联系人的核心信息表 ,它是个多态表,不同数据(电话、邮箱、姓名等)共用同一张表,字段复用。主要靠两点区分:
| 字段 | 说明 |
|---|---|
MIMETYPE |
指明这一行记录的数据类型(如电话、姓名、邮箱等) |
DATA1 ~ DATA15 |
存储不同类型的具体数据,每种类型的字段映射不同 |
| 数据类型(MIMETYPE) | 对应类 | 常用字段 | 字段意义 |
|---|---|---|---|
StructuredName.CONTENT_ITEM_TYPE |
姓名 | DATA1 = display name,DATA2 = given name(名)DATA3 = family name(姓) |
如果你只设置显示名,直接用 DATA1 |
Phone.CONTENT_ITEM_TYPE |
电话 | DATA1 = 电话号码,DATA2 = 电话类型(TYPE_MOBILE等) |
电话必须写 DATA1 |
Email.CONTENT_ITEM_TYPE |
邮箱 | DATA1 = 邮箱地址。DATA2 = 邮箱类型(TYPE_WORK等) |
邮箱地址也放在 DATA1 |
方式一:逐条插入联系人
🔧 插入步骤说明
-
插入空 RawContact 得到 raw_contact_id
-
插入姓名(MIME_TYPE 为
StructuredName) -
插入电话(MIME_TYPE 为
Phone) -
插入邮箱(MIME_TYPE 为
Email)
java
/**
* 往手机通讯录添加一个联系人信息(包括姓名、电话号码、电子邮箱)。
* 此方法通过 ContentResolver 向 ContactsContract.RawContacts 和 ContactsContract.Data 表中插入数据,
* 实现一个完整联系人的构建。
*
* @param resolver ContentResolver 用于访问系统通讯录数据库
* @param contact 包含联系人姓名、电话和邮箱的对象
*/
private void addContacts(ContentResolver resolver, Contact contact) {
// ---------- 步骤一:向 RawContacts 表插入空值,创建一个新的联系人 ----------
ContentValues values = new ContentValues();
// 插入空值,并获取系统分配的 raw_contact_id(唯一联系人ID)
Uri uri = resolver.insert(ContactsContract.RawContacts.CONTENT_URI, values);
long rawContactId = ContentUris.parseId(uri); // 获取返回的 ID
// ---------- 步骤二:向 Data 表插入姓名 ----------
ContentValues name = new ContentValues();
name.put(ContactsContract.Data.RAW_CONTACT_ID, rawContactId); // 关联 raw_contact_id
name.put(Contacts.Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE); // 指定数据类型为"姓名"
name.put(Contacts.Data.DATA2, contact.name); // DATA2 表示给出的名字字段
resolver.insert(ContactsContract.Data.CONTENT_URI, name); // 插入姓名记录
// ---------- 步骤三:向 Data 表插入电话号码 ----------
ContentValues phone = new ContentValues();
phone.put(ContactsContract.Data.RAW_CONTACT_ID, rawContactId); // 关联 raw_contact_id
phone.put(Contacts.Data.MIMETYPE, CommonDataKinds.Phone.CONTENT_ITEM_TYPE); // 指定数据类型为"电话"
phone.put(Contacts.Data.DATA1, contact.phone); // DATA1 为电话号码
phone.put(Contacts.Data.DATA2, CommonDataKinds.Phone.TYPE_MOBILE); // DATA2 为电话号码类型(手机)
resolver.insert(ContactsContract.Data.CONTENT_URI, phone); // 插入电话记录
// ---------- 步骤四:向 Data 表插入电子邮箱 ----------
ContentValues email = new ContentValues();
email.put(ContactsContract.Data.RAW_CONTACT_ID, rawContactId); // 关联 raw_contact_id
email.put(Contacts.Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE); // 指定数据类型为"邮箱"
email.put(Contacts.Data.DATA1, contact.email); // DATA1 为邮箱地址
email.put(Contacts.Data.DATA2, CommonDataKinds.Email.TYPE_WORK); // DATA2 为邮箱类型(工作邮箱)
resolver.insert(ContactsContract.Data.CONTENT_URI, email); // 插入邮箱记录
}
示例代码要点说明
java
Uri uri = resolver.insert(ContactsContract.RawContacts.CONTENT_URI, new ContentValues());
long rawContactId = ContentUris.parseId(uri); // 获取联系人 ID
每个字段都通过 .put(RAW_CONTACT_ID, id) 与这个联系人绑定。
方式二:批处理插入联系人(事务推荐)
✨ 优势
-
原子性:要么都成功,要么都失败,避免数据残留。
-
性能好:一次批量提交,系统只调一次数据库。
构建流程
-
每一个
ContentProviderOperation表示一个插入动作 -
withValueBackReference用于引用第一步插入返回的 ID -
最后调用
applyBatch()执行事务
java
contentResolver.applyBatch(ContactsContract.AUTHORITY, operations);
该方法用于一次性向手机通讯录中添加一个完整联系人 (包括姓名、电话、邮箱),采用了**批处理事务(applyBatch)**方式。
java
private void addFullContacts(ContentResolver contentResolver, Contact contact) {
// 插入 raw_contact 表(只要空值即可)
ContentProviderOperation op_main = ContentProviderOperation
.newInsert(ContactsContract.RawContacts.CONTENT_URI)
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
.build();
// 插入姓名
ContentProviderOperation op_name = ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Contacts.Data.RAW_CONTACT_ID, 0)
.withValue(Contacts.Data.MIMETYPE, CommonDataKinds.StructuredName.CONTENT_ITEM_TYPE)
.withValue(Contacts.Data.DATA1, contact.name) // display name
.build();
// 插入电话
ContentProviderOperation op_phone = ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Contacts.Data.RAW_CONTACT_ID, 0)
.withValue(Contacts.Data.MIMETYPE, CommonDataKinds.Phone.CONTENT_ITEM_TYPE)
.withValue(Contacts.Data.DATA1, contact.phone)
.withValue(Contacts.Data.DATA2, CommonDataKinds.Phone.TYPE_MOBILE)
.build();
// 插入邮箱
ContentProviderOperation op_email = ContentProviderOperation
.newInsert(ContactsContract.Data.CONTENT_URI)
.withValueBackReference(Contacts.Data.RAW_CONTACT_ID, 0)
.withValue(Contacts.Data.MIMETYPE, CommonDataKinds.Email.CONTENT_ITEM_TYPE)
.withValue(Contacts.Data.DATA1, contact.email)
.withValue(Contacts.Data.DATA2, CommonDataKinds.Email.TYPE_WORK)
.build();
ArrayList<ContentProviderOperation> operationList = new ArrayList<>();
operationList.add(op_main);
operationList.add(op_name);
operationList.add(op_phone);
operationList.add(op_email);
try {
contentResolver.applyBatch(ContactsContract.AUTHORITY, operationList);
} catch (OperationApplicationException | RemoteException e) {
throw new RuntimeException("添加联系人失败", e);
}
}
1️⃣ ContentProviderOperation 是什么?
它是一个操作对象,表示你要对某个表执行"插入 / 更新 / 删除"动作。你可以把多个操作打包成一个集合,最后统一执行,确保事务一致性。
-
插入联系人时会涉及两个表:
-
RawContacts.CONTENT_URI:插入联系人主体,获取 ID -
Data.CONTENT_URI:插入联系人具体信息(如姓名、电话、邮箱)
-
2️⃣ 什么是 withValueBackReference()?
这个方法的作用是:
让后续操作引用前一个插入操作的返回 ID(raw_contact_id)。
java
.withValueBackReference(Contacts.Data.RAW_CONTACT_ID, 0)
注意,
在插入联系人到 RawContacts 表时,一定要加上以下代码不然会插入失败
java
.withValue(ContactsContract.RawContacts.ACCOUNT_TYPE, null)
.withValue(ContactsContract.RawContacts.ACCOUNT_NAME, null)
这是告诉系统"我要把联系人存储到本地账户,而不是某个同步账户(如 Google、Exchange)中"。
如果你不加,部分手机或 ROM(尤其国产 ROM)可能会插入失败或联系人不显示,因为系统无法判断该联系人属于哪个账户类型。
利用ContentObserve监听短信
什么是 ContentObserver?
-
Android 提供的内容监听器。
-
可以用来监听数据库表或系统内容的变化,如:短信、联系人、通话记录等。
-
一旦监听的内容发生变化,会触发
onChange()方法。
监听短信基本步骤
步骤总览:
-
创建
ContentObserver子类,并重写onChange()方法。 -
使用
registerContentObserver()注册监听content://sms。 -
在
onDestroy()中注销监听。
java
public class MonitorSmsActivity extends AppCompatActivity {
private SmsGetObserver mObserver;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_monitor_sms);
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
// 给指定Uri注册内容观察器,一旦发生数据变化,就触发观察器的onChange方法
Uri uri = Uri.parse("content://sms");
// notifyForDescendents:
// false:表示精确匹配,即只匹配该Uri,true:表示可以同时匹配其派生的Uri
// 假设UriMatcher 里注册的Uri共有一下类型:
// 1.content://AUTHORITIES/table
// 2.content://AUTHORITIES/table/#
// 3.content://AUTHORITIES/table/subtable
// 假设我们当前需要观察的Uri为content://AUTHORITIES/student:
// 如果发生数据变化的 Uri 为 3。
// 当notifyForDescendents为false,那么该ContentObserver会监听不到,但是当notifyForDescendents
mObserver = new SmsGetObserver(this);
// 注册
getContentResolver().registerContentObserver(uri, true, mObserver);
}
private static class SmsGetObserver extends ContentObserver{
private final Context mContext;
public SmsGetObserver(Context context){
super(new Handler(Looper.getMainLooper()));
this.mContext = context;
}
// 收到短信
// onChange会多次调用,收到一条短信会调用两次onChange
// mUri===content://sms/raw/20
// mUri===content://sms/inbox/20
// 安卓7.0以上系统,点击标记为已读,也会调用一次
// mUri===content://sms
//收到一条短信都是uri后面都会有确定的一个数字,对应数据库的id,比如上面的20
@Override
@SuppressLint("Range")
public void onChange(boolean selfChange, @Nullable Uri uri) {
super.onChange(selfChange, uri);
if(uri == null){
return;
}
if(uri.toString().contains("content://sms/raw") ||
uri.toString().equals("content://sms") ){
return;
}
// 通过内容解析器获取符合条件的结果集游标
Cursor cursor = mContext.getContentResolver().query(uri, new String[]{"address", "body", "date"}, null, null, "date desc");
// 根据日期排序拿到最新的那一条
if(cursor.moveToNext()){
// 短信的发送号码
String sender = cursor.getString(cursor.getColumnIndex("address"));
// 短信内容
String content = cursor.getString(cursor.getColumnIndex("body"));
Log.d("ning",String.format("sender:%s,content:%s",sender,content));
}
cursor.close();
}
}
@Override
protected void onDestroy() {
// 取消注册
getContentResolver().unregisterContentObserver(mObserver);
super.onDestroy();
}
}
监听的 URI 是什么?
java
Uri uri = Uri.parse("content://sms");
-
content://sms是系统短信数据库的路径,表示监听所有短信数据表(如收件箱、草稿箱、发件箱)。 -
等价于访问短信数据库表
sms。
为什么设置 true:
java
getContentResolver().registerContentObserver(uri, true, mObserver);
| 参数 | 含义 |
|---|---|
true |
监听所有子路径,如 content://sms/inbox/20、content://sms/raw/21 |
false |
只监听 content://sms 本身,无法获取具体短信变化 |
重写 onChange() 方法:
java
public void onChange(boolean selfChange, @Nullable Uri uri)
-
uri表示发生变化的具体内容地址。 -
监听短信时,一条新短信可能触发两次:
-
一次是
content://sms/raw/xx(临时存储) -
一次是
content://sms/inbox/xx(正式入库)
-
-
安卓 7.0+ 标记已读也会触发一次
onChange
忽略无效 uri 的判断逻辑:
java
if(uri.toString().contains("content://sms/raw") || uri.toString().equals("content://sms")){
return;
}
-
sms/raw是临时记录(短信还没完全写入) -
sms是总表,变化太频繁 -
所以你只处理
sms/inbox/xxx等真正落库的 URI 更稳妥
onChange 中的处理逻辑
java
// 通过内容解析器获取符合条件的结果集游标
Cursor cursor = mContext.getContentResolver().query(uri, new String[]{"address", "body", "date"}, null, null, "date desc");
// 根据日期排序拿到最新的那一条
if(cursor.moveToNext()){
// 短信的发送号码
String sender = cursor.getString(cursor.getColumnIndex("address"));
// 短信内容
String content = cursor.getString(cursor.getColumnIndex("body"));
Log.d("ning",String.format("sender:%s,content:%s",sender,content));
}
cursor.close();
| 字段 | 含义 |
|---|---|
address |
发件人手机号 |
body |
短信内容 |
date |
发送时间 |
可以按 date desc 排序,拿到最新的短信信息。
取消监听的好习惯:
java
@Override
protected void onDestroy() {
getContentResolver().unregisterContentObserver(mObserver);
super.onDestroy();
}
防止内存泄漏或误监听,及时解绑 ContentObserver
如何验证接收短信?


监听 + 通知流程
总体原理(类比广播)
| 谁 | 做了什么 | 说明 |
|---|---|---|
| 监听者 | 注册了 ContentObserver 去监听某个 URI |
"我关心这个数据变化,请通知我" |
| 数据提供者(如 ContentProvider) | 在数据变化后调用 notifyChange() |
"我这边数据变了,通知所有监听我 URI 的人" |
| 系统 | 找到匹配的观察器并回调它的 onChange() |
"你监听的数据变了,来处理吧!" |
流程如下
监听方调用:
java
getContentResolver().registerContentObserver(uri, true, observer);
表示:"我想监听这个 uri 数据有没有变化"。
当 ContentProvider 插入或更新数据,并调用:
java
getContext().getContentResolver().notifyChange(uri, null);
表示:"告诉大家,这个 URI 的数据变了!"
系统自动回调监听器的:
java
@Override
public void onChange(boolean selfChange, Uri uri) {
// 你在这里处理变化(比如更新 UI)
}
在应用之间共享文件
使用相册图片发送彩信
彩信(MMS)支持的媒体附件包括图片、视频、音频、文本。你可以选择相册图片后,通过彩信发送:
1. 打开系统图库选择图片
ActivityResultLauncher + StartActivityForResult 新写法来处理"打开相册并获取用户选择的图片"
java
ActivityResultLauncher<Intent> resultLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(), new ActivityResultCallback<ActivityResult>() {
// 回调函数
@Override
public void onActivityResult(ActivityResult result) {
if (result.getResultCode() == RESULT_OK) {
Uri imageUri = result.getData().getData();
// 这里就可以使用 imageUri 显示或上传了
}
}
}
);
2. 需要的权限声明
📌 Manifest 中声明(运行前准备):
java
<!-- Android 13+ -->
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<!-- Android 12 及以下 -->
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
运行时动态申请(否则会打不开图库或选了没反应):
java
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_MEDIA_IMAGES)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_MEDIA_IMAGES}, 1);
}
} else {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, 1);
}
}
3.如何获取选中的图片
java
Uri imageUri = result.getData().getData(); // content:// 开头的 Uri
imageView.setImageURI(imageUri); // 可直接显示
4.如何发送彩信?
-
构造
Intent.ACTION_SEND -
设置
setType("image/*") -
添加
Intent.EXTRA_STREAM(即图片的 URI) -
添加
address、sms_body等字段 -
一定要加:
FLAG_GRANT_READ_URI_PERMISSION,发送文件 URI 时一定要授权,否则接收方无法读取
java
// 发送带图片的彩信
private void sendMms(String phone, String name) {
Intent intent = new Intent(Intent.ACTION_SEND);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// Intent的接受者将被准许读取Intent携带的URI数据
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// 收件人号码
intent.putExtra("address",phone);
// 标题
intent.putExtra("subject",name);
// 正文
intent.putExtra("sms_body","彩信正文");
// 附件
intent.putExtra(Intent.EXTRA_STREAM,picData);
//彩信的附件类型为图片
intent.setType("image/*");
startActivity(intent);
Toast.makeText(this,"请在弹窗中选择短信或者信息应用",Toast.LENGTH_SHORT);
}
借助FileProvider发送彩信
为什么要用 FileProvider?
从 Android 7.0(API 24)起,直接通过 file:// URI 共享文件会抛出 FileUriExposedException,因为系统不再允许向外部应用暴露私有文件路径。必须使用 content:// URI ------ 这正是 FileProvider 提供的作用:
FileProvider 的功能:
-
将 app 私有目录中的文件转换为 content:// URI;
-
通过授权方式允许其他 app 访问指定的文件;
-
提高应用的安全性与兼容性。
一、基本原理
发送彩信的目标是通过 Intent 触发系统短信应用,携带文字和图片:
java
Intent intent = new Intent(Intent.ACTION_SEND);
intent.setType("image/*");
intent.putExtra(Intent.EXTRA_STREAM, imageUri); // 图片 Uri
intent.putExtra("address", phoneNumber); // 收件人
intent.putExtra("sms_body", messageText); // 彩信内容
intent.setPackage("com.android.mms"); // 设定使用默认短信应用
startActivity(intent);
重点在于: imageUri 需要是通过 FileProvider 转换的 content:// URI。
二、Manifest 权限与配置准备
1. 添加必需权限
XML
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
-
SEND_SMS:发送短信或彩信必须权限 -
READ_MEDIA_IMAGES:Android 13+ 获取图片权限 -
READ_EXTERNAL_STORAGE:Android 10 及以下读取图片
2. FileProvider 注册
在 <application> 中添加 FileProvider:
XML
<!-- @string/file_provider 对应 Java 中调用 -->
<!-- 配置provider -->
<!-- 兼容Android7.0,把访问文件的Uri方式改为FileProvider -->
<!-- android:grantUriPermissions 必须设型为true -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="@string/file_provider"
android:grantUriPermissions="true">
<!-- 配置哪些路径是可以通过FileProvider访问的 -->
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
详细解释:
| 属性 | 作用 |
|---|---|
android:name |
指定 FileProvider 的类名,这里使用官方提供的 androidx.core.content.FileProvider,无需自定义 |
android:authorities |
唯一标识 FileProvider ,用于 Java 代码中调用 getUriForFile() 时提供。建议放在 strings.xml 中统一管理 |
android:grantUriPermissions |
必须设置为 true,允许临时授权目标 App 访问共享文件 |
<meta-data> |
声明文件路径的配置文件在哪,用来告诉 FileProvider 哪些文件可以被共享(通常是 /res/xml/file_paths.xml) |
📌 提醒:
-
authorities应确保唯一性 ,通常使用包名作为前缀,如:com.example.myapp.fileProvider -
若多个 app 有同名
authorities会冲突,导致运行时崩溃
4. 配置 strings.xml
XML
<resources>
<string name="app_name">chapter04-client</string>
<string name="file_provider">com.example.chapter04_client.fileProvider</string>
</resources>
只是一个字符串资源,它的作用是:
告诉系统:我这个 FileProvider 的唯一标识符是
com.example.chapter04_client.fileProvider,用这个去注册、访问、匹配。
5. 配置 file_paths.xml
创建文件:res/xml/file_paths.xml,内容如下:
XML
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- SD 卡的download下面的文件
name可以随便取-->
<external-path
name="external_storage_download"
path="Download"/>
<!-- sdcard下所有的文件都可以访问-->
<!-- <external-path-->
<!-- name="external_storage_root"-->
<!-- path="."/>-->
</paths>
| 标签 | 含义 |
|---|---|
<paths> |
FileProvider 的路径声明根标签,必须包含 xmlns:android 命名空间 |
<external-path> |
映射的是外部存储根目录(如 /storage/emulated/0/) |
name 属性 |
给这个路径取个别名,Java 代码不使用,仅用于记录 |
path="." |
表示共享整个外部路径的所有子目录,比如 Download/, DCIM/, Pictures/ |
三、使用 MediaStore 加载图片的关键
1. MediaStore 是什么?
MediaStore 是 Android 系统提供的一个 内容提供器(ContentProvider),用于访问设备上的多媒体文件(如图片、视频、音频等)的元数据。
它的作用类似于一个"媒体数据库",你可以像操作数据库一样通过查询语句获取文件信息。
2. 为什么需要手动扫描文件夹?
-
Android 系统有一个"媒体扫描器"用于定期 或开机时 扫描外部存储,把图片、音频、视频等注册进系统的媒体数据库
MediaStore。 -
但它不会实时监控所有文件变化。
-
如果你在程序中保存了新图片(如下载了一张图片到
/Download),但没有通知系统扫描:-
图库里看不到
-
用
MediaStore.Images.Media查询不到 -
发送彩信或上传时也无法选择该图
-
java
// 手动让MediaStore扫描入库
MediaScannerConnection.scanFile(this,
new String[]{Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).toString()},null,null);
等同于说:
"嘿 Android 系统,请立刻重新扫描这个目录,把其中的新文件加入到媒体库里!"
手动调用 MediaScannerConnection.scanFile() 是为了让系统立刻知道你新保存的文件存在,并能马上通过图库或 MediaStore 正常访问到它,是"同步媒体数据库"的关键一步。
3. 查询图片的常用 URI 与字段
bash
MediaStore.Images.Media.EXTERNAL_CONTENT_URI
表示外部存储上的图片(例如 /sdcard/DCIM/、/sdcard/Download/)
常用字段:
| 字段常量 | 描述 |
|---|---|
MediaStore.Images.Media._ID |
图片的唯一编号 |
MediaStore.Images.Media.TITLE |
图片标题(文件名,不包含后缀) |
MediaStore.Images.Media.SIZE |
图片大小(单位:字节) |
MediaStore.Images.Media.DATA |
图片的完整路径(注意:Android Q 起已废弃!) |
⚠️ Android Q(10)及以后建议用
ContentResolver.openInputStream(uri)获取实际图片内容,而不是使用DATA路径。
4. 从系统媒体库中加载符合条件的图片列表,并保存在 mImageList 中备用代码
java
@SuppressLint("Range")
private void loadImageList() {
// MediaStore 可能会访问到非可访问目录下的文件
// 指定要查询的列字段,包括图片编号、标题、大小、路径。
String[] columns = new String[]{
MediaStore.Images.Media._ID,// 编号
MediaStore.Images.Media.TITLE,// 标题
MediaStore.Images.Media.SIZE,// 文件大小
MediaStore.Images.Media.DATA// 文件路径
};
// 图片大小在1M以内
Cursor cursor = getContentResolver().query(
MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
columns,
"_size<1048576",
null,
"_size DESC" //结果按照大小从大到小排序
);
int count = 0;
if(cursor != null){
// 遍历结果 Cursor,最多获取 6 张图片
while(cursor.moveToNext() && count < 6){
ImageInfo image = new ImageInfo();
image.id = cursor.getLong(cursor.getColumnIndex(MediaStore.Images.Media._ID));
image.name = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.TITLE));
image.size = cursor.getLong(cursor.getColumnIndex(MediaStore.Images.Media.SIZE));
image.path = cursor.getString(cursor.getColumnIndex(MediaStore.Images.Media.DATA));
// 如果符合FileProvider则加入集合
if(FileUtil.checkFileUri(this,image.path)){
count++;
mImageList.add(image);
}
Log.d("ning","image:"+image.toString());
}
}
}
-
每次取出一张图片的信息,封装成
ImageInfo对象。 -
调用
FileUtil.checkFileUri(...)判断图片是否位于 FileProvider 可共享路径中(例如/storage/emulated/0/Download/)。 -
只加入符合条件的前 6 张图片到
mImageList。
5.FileUtil中的checkFileUri
验证某个文件路径是否有效,并能被当前 App 的 FileProvider 成功生成 content:// URI,用于安全共享给其他 App(如短信/彩信 App)
java
// 判断一个图片文件路径是否是合法的,并且是否能够被 FileProvider 成功共享
public static boolean checkFileUri(Context ctx,String path){
File file = new File(path);
Log.d("ning","old path:"+path);
if(!file.exists() || !file.isFile() || file.length() <= 0) return false;
try {
//检测文件路径是否支持 FileProvider 访问方式,如果发生异常,说明不支持
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
FileProvider.getUriForFile(ctx, ctx.getString(R.string.file_provider), file);
}
}catch (Exception e){
e.printStackTrace();
return false;
}
return true;
}
-
尝试通过 FileProvider 为这个文件生成 content:// 类型的 URI
-
如果生成失败(比如路径不在
file_paths.xml声明的范围内),getUriForFile()会抛异常 → 返回false -
否则说明这个路径合法、受支持 → 继续执行
四、使用FileProvider发送带图片的彩信
构建一个带文字内容和图片附件的彩信(MMS)发送 Intent,通过 FileProvider 共享图片文件,兼容 Android 7.0+。
java
private void sendMms(String phone, String name, String message, String path)
| 参数 | 含义 |
|---|---|
phone |
收件人手机号 |
name |
彩信标题(subject) |
message |
彩信正文内容 |
path |
图片文件的本地路径 |
① 创建 URI
java
Uri uri = Uri.parse(path);
初步将文件路径转换成 URI,但注意这只是
file://形式的 URI(不安全,不能跨进程共享)
② 使用 FileProvider 转换为 content:// URI
java
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
uri = FileProvider.getUriForFile(
this,
getString(R.string.file_provider), // 例如 "com.example.chapter04_client.fileProvider"
new File(path)
);
Log.d("ning", "new uri:" + uri.toString());
}
Android 7.0(API 24)及以后系统禁止使用 file:// URI 跨进程传递文件
FileProvider 是官方推荐的解决方案,它会把
file://变为content://并且做权限封装
③ 构建 Intent
java
Intent intent = new Intent(Intent.ACTION_SEND);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
| 意图类型 | 说明 |
|---|---|
ACTION_SEND |
启动系统发送界面(可选择短信/彩信 App) |
FLAG_ACTIVITY_NEW_TASK |
如果从非 Activity 中启动需要此 flag |
FLAG_GRANT_READ_URI_PERMISSION |
授权对方 App 临时读取图片(content://) |
④ 添加彩信内容
java
intent.putExtra("address", phone); // 收件人号码
intent.putExtra("subject", name); // 彩信标题
intent.putExtra("sms_body", message); // 彩信正文内容
intent.putExtra(Intent.EXTRA_STREAM, uri); // 彩信附件:图片
intent.setType("image/*"); // 附件类型为图片
❗ 注意:虽然 "address" 和 "sms_body" 并不是标准 EXTRA,但很多系统短信 App 能识别这些字段。
⑤ 启动 Intent
java
startActivity(intent);
Toast.makeText(this, "请在弹窗中选择短信或者信息应用", Toast.LENGTH_SHORT);
-
启动系统的发送界面(如"信息"App)
-
弹出 Toast 提示用户选择发送应用
示例流程
bash
Activity onCreate()
↓
手动扫描 /Download 目录
↓
检查 READ_MEDIA_IMAGES 权限
↓
如果已授权 → 加载图片列表
↓
查询 MediaStore 图片(<1MB)
↓
检查每张图是否支持 FileProvider
↓
添加到 mImageList 并显示网格图
↓
用户点击某张图片
↓
→ 调用 sendMms() 构建 Intent
↓
→ 使用 FileProvider 转换 content://
↓
→ 调用系统彩信应用发送图片
java
public class ProviderMmsActivity extends AppCompatActivity {
private List<ImageInfo> mImageList = new ArrayList<>();
private GridLayout gl_appendix;
// 读写权限
private static final String[] PERMISSIONS = new String[]{
Manifest.permission.READ_MEDIA_IMAGES
};
// 区分不同权限请求(例如拍照权限、存储权限、定位权限)
private static final int PERMISSION_REQUEST_CODE=1;
private TextView et_phone;
private TextView et_name;
private TextView et_content;
// 弹框的选择
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
// 权限被允许
if(requestCode == PERMISSION_REQUEST_CODE &&
PermissionUtil.checkGrant(grantResults)){
// 加载图片列表
loadImageList();
// 将图片显示在图像网格当中
showImageGrid();
}
}
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
EdgeToEdge.enable(this);
setContentView(R.layout.activity_provider_mms);
ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main), (v, insets) -> {
Insets systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars());
v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom);
return insets;
});
gl_appendix = findViewById(R.id.gl_appendix);
et_phone = findViewById(R.id.et_phone);
et_name = findViewById(R.id.et_name);
et_content = findViewById(R.id.et_content);
// 手动让MediaStore扫描入库
MediaScannerConnection.scanFile(this,
new String[]{Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS).toString()},
null,null);
if (PermissionUtil.checkPermission(this,PERMISSIONS,PERMISSION_REQUEST_CODE)) {
// 加载图片列表
loadImageList();
// 将图片显示在图像网格当中
showImageGrid();
}
}
private void showImageGrid() {
gl_appendix.removeAllViews();
for (ImageInfo image : mImageList) {
// image->ImageView然后再添加到GridLayout当中
ImageView iv_appendix = new ImageView(this);
Bitmap bitmap = BitmapFactory.decodeFile(image.path);
iv_appendix.setImageBitmap(bitmap);
// 设置图像缩放类型
iv_appendix.setScaleType(ImageView.ScaleType.FIT_CENTER);
// 设置图像视图的布局参数
int px = Utils.dip2px(this,110);
ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(px,px);
iv_appendix.setLayoutParams(params);
// 内部间距
int padding = Utils.dip2px(this,5);
iv_appendix.setPadding(padding,padding,padding,padding);
iv_appendix.setOnClickListener(v->{
sendMms(et_phone.getText().toString(),
et_name.getText().toString(),
et_content.getText().toString(),
image.path);
});
gl_appendix.addView(iv_appendix);
}
}
// 显示6张
@SuppressLint("Range")
private void loadImageList() {}
// 发送带图片的彩信
private void sendMms(String phone, String name,String message,String path) {
}
}
五、借助FileProvider安装应用
一、为什么要使用 FileProvider 安装 APK?
❗ 背景问题
从 Android 7.0(API 24)开始 ,系统禁止通过 file:// URI 在 App 间传递文件,尤其是用于 APK 安装。
如果你用传统方式:
java
Uri uri = Uri.parse("file:///sdcard/xxx.apk");
会导致报错:
FileUriExposedException: file:// URI exposure is not allowed
✅ 解决方案:使用 FileProvider
将本地文件路径转换为安全的 content:// URI,并授权访问,避免暴露 file 路径。
二、流程概览
bash
用户点击"安装"按钮
↓
Android 11+ 检查 MANAGE_EXTERNAL_STORAGE 权限
↓
如果未授权 → 跳转设置
如果已授权或版本较低 → 检查动态权限
↓
构造 APK 文件路径(Download/chapter-01-release.apk)
↓
验证 APK 文件是否有效(PackageInfo != null)
↓
通过 FileProvider 获取 content:// URI
↓
构造 ACTION_VIEW + GRANT_READ_URI_PERMISSION 的 Intent
↓
启动系统安装器安装 APK
三、关键步骤与知识点拆解
1️⃣ 权限处理(分版本)
Android 11(API 30)及以上:
必须获取"管理所有文件 "权限:MANAGE_EXTERNAL_STORAGE
Manifest 权限声明
XML
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
java动态管理
java
@RequiresApi(api = Build.VERSION_CODES.R)
private void checkAndInstall() {
// 检查是否拥有MANAGE EXTERNAL STORAGE 权限,没有则跳转到设置页面
if(!Environment.isExternalStorageManager()){
// 设置页面的构建
Intent intent = new Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION);
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
intent.setData(Uri.fromParts("package",getPackageName(),null));
startActivity(intent);
}else installApk();
}
该方法只能在 Android 11(API 30 / R)及以上系统上运行, 判断是否拥有"管理所有文件权限"
如果没有权限 → 跳转到系统设置页面:
Android 10 及以下:
动态申请 READ_MEDIA_IMAGES 或 READ_EXTERNAL_STORAGE 即可访问公有目录
Manifest 权限声明
XML
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
java动态管理
java
// 读写权限
private static final String[] PERMISSIONS = new String[]{
Manifest.permission.READ_MEDIA_IMAGES
};
// 区分不同权限请求(例如拍照权限、存储权限、定位权限)
private static final int PERMISSION_REQUEST_CODE=1;
if (PermissionUtil.checkPermission(this, PERMISSIONS, REQUEST_CODE)) {
installApk();
}
// 判断用户选择是否同意权限的回调函数
@Override
public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
if(requestCode == PERMISSION_REQUEST_CODE &&
PermissionUtil.checkGrant(grantResults)){
installApk();
}
}
2️⃣ APK 路径的构造
java
String apkPath = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS) + "/chapter-01-release.apk";
-
表示 APK 文件保存在系统的
/Download/公共目录中 -
可通过文件管理器等方式手动放入
3️⃣ 校验 APK 文件是否有效
java
//获取应用包管理器
PackageManager pm = getPackageManager();
// 获取apk文件的包信息
PackageInfo pi = pm.getPackageArchiveInfo(apkPath, PackageManager.GET_ACTIVITIES);
if(pi == null){
// 文件损坏
Toast.makeText(this,"安装文件已经损坏了",Toast.LENGTH_SHORT).show();
return;
}
-
尝试解析该 APK 文件的包信息
-
如果返回
null,表示文件可能已损坏
4️⃣ 获取 FileProvider 的 content:// URI
java
// 交给系统启用安装
Uri uri = Uri.parse(apkPath);
// 兼容Android7.0,把访问文件的Uri方式改为FileProvider
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
// 通过FileProvider获得文件的Uri访问方式
uri = FileProvider.getUriForFile(this, getString(R.string.file_provider), new File(apkPath));
Log.d("ning","new uri:"+uri.toString());
}
-
getUriForFile(...)会根据你配置的file_paths.xml返回安全的content://URI -
例如:
content://com.example.chapter04_client.fileProvider/external_files/Download/chapter-01-release.apk
✅ 记得配置 AndroidManifest.xml 中的 FileProvider 和路径规则!
5️⃣ 构建安装 Intent 并启动安装器
java
// 创建一个用于查看(打开)指定内容的 Intent(适用于安装器)
Intent intent = new Intent(Intent.ACTION_VIEW);
// 设置数据和 MIME 类型
// uri 是 APK 文件的 content:// 形式 URI(通过 FileProvider 获取)
// "application/vnd.android.package-archive" 是 APK 的 MIME 类型
intent.setDataAndType(uri, "application/vnd.android.package-archive");
// 授权目标应用(安装器)临时读取该 URI,否则安装器将无法访问 APK 文件内容
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
// 启动新任务栈,避免非 Activity Context 启动崩溃(如从 Service 调用)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
// 启动系统安装器应用,开始安装流程
startActivity(intent);
-
ACTION_VIEW+application/vnd.android.package-archive表示打开安装器安装 APK -
必须加
FLAG_GRANT_READ_URI_PERMISSION:授权目标 App(安装器)读取 URI
FileProvider总结
一、什么是 FileProvider?
FileProvider是 Android 提供的一个 ContentProvider 子类,用于安全地在 App 之间共享文件。
从 Android 7.0(API 24)开始 ,系统禁止直接使用 file:// URI 在应用间传递文件,必须使用 content:// URI,而 FileProvider 正是官方推荐的解决方案。
二、FileProvider 解决了什么问题?
| 问题 | 解决方式 |
|---|---|
| Android 7.0+ 禁止 file:// 跨应用传递 | FileProvider 使用 content:// URI 安全封装文件路径 |
| 需要授权目标应用访问共享文件 | 自动配合 FLAG_GRANT_READ_URI_PERMISSION 使用 |
| URI 权限限制、访问受控 | 只开放配置在 file_paths.xml 中的目录 |
三、FileProvider如何使用?
java
uri = FileProvider.getUriForFile(
this, // 当前 Context
getString(R.string.file_provider), // 在 strings.xml 配置的 authority,如 com.example.app.fileProvider
new File(apkPath) // 要共享的本地文件(APK)
);
👉 把 File(本地路径)转换成内容 URI(content:// 格式)
-
Android 7.0(API 24)开始,不能直接把
file://的路径分享给其他 App (会抛出FileUriExposedException) -
所以你必须用 FileProvider 把 file 转成 content Uri
-
然后再加上
Intent.FLAG_GRANT_READ_URI_PERMISSION权限,其他 App 才能读取这个文件
