Android 短信收发模块从零到实战:权限配置、界面设计、发送接收与兼容性适配全攻略

摘要:本文从零开始,手把手带你实现一套完整的 Android 短信收发模块。内容涵盖开发环境搭建与权限配置、短信列表界面设计、读取手机短信数据、新建发送界面交互、调用系统 API 发送短信、监听并处理收到短信的回复机制,以及完整流程联调与真机测试验证。同时,针对常见权限报错、运行时异常排查、不同 Android 版本的兼容性适配等痛点给出可落地的解决方案,并分享项目优化方向与安全隐私注意事项,帮助新手开发者避开过时教程的陷阱,快速跑通短信功能并适配当前主流开发环境。
关键词:Android;短信收发;权限配置;BroadcastReceiver;SmsManager;ContentProvider;运行时权限;真机测试;兼容性适配

目录

  • [① 开发环境搭建与权限配置要点](#① 开发环境搭建与权限配置要点 "#%E2%91%A0-%E5%BC%80%E5%8F%91%E7%8E%AF%E5%A2%83%E6%90%AD%E5%BB%BA%E4%B8%8E%E6%9D%83%E9%99%90%E9%85%8D%E7%BD%AE%E8%A6%81%E7%82%B9")
  • [② 短信列表界面布局设计与实现](#② 短信列表界面布局设计与实现 "#%E2%91%A1-%E7%9F%AD%E4%BF%A1%E5%88%97%E8%A1%A8%E7%95%8C%E9%9D%A2%E5%B8%83%E5%B1%80%E8%AE%BE%E8%AE%A1%E4%B8%8E%E5%AE%9E%E7%8E%B0")
  • [③ 读取手机短信数据的代码逻辑](#③ 读取手机短信数据的代码逻辑 "#%E2%91%A2-%E8%AF%BB%E5%8F%96%E6%89%8B%E6%9C%BA%E7%9F%AD%E4%BF%A1%E6%95%B0%E6%8D%AE%E7%9A%84%E4%BB%A3%E7%A0%81%E9%80%BB%E8%BE%91")
  • [④ 新建短信发送界面的交互设计](#④ 新建短信发送界面的交互设计 "#%E2%91%A3-%E6%96%B0%E5%BB%BA%E7%9F%AD%E4%BF%A1%E5%8F%91%E9%80%81%E7%95%8C%E9%9D%A2%E7%9A%84%E4%BA%A4%E4%BA%92%E8%AE%BE%E8%AE%A1")
  • [⑤ 调用系统 API 实现短信发送功能](#⑤ 调用系统 API 实现短信发送功能 "#%E2%91%A4-%E8%B0%83%E7%94%A8%E7%B3%BB%E7%BB%9F-api-%E5%AE%9E%E7%8E%B0%E7%9F%AD%E4%BF%A1%E5%8F%91%E9%80%81%E5%8A%9F%E8%83%BD")
  • [⑥ 监听并处理收到短信的回复机制](#⑥ 监听并处理收到短信的回复机制 "#%E2%91%A5-%E7%9B%91%E5%90%AC%E5%B9%B6%E5%A4%84%E7%90%86%E6%94%B6%E5%88%B0%E7%9F%AD%E4%BF%A1%E7%9A%84%E5%9B%9E%E5%A4%8D%E6%9C%BA%E5%88%B6")
  • [⑦ 完整流程联调与真机测试验证](#⑦ 完整流程联调与真机测试验证 "#%E2%91%A6-%E5%AE%8C%E6%95%B4%E6%B5%81%E7%A8%8B%E8%81%94%E8%B0%83%E4%B8%8E%E7%9C%9F%E6%9C%BA%E6%B5%8B%E8%AF%95%E9%AA%8C%E8%AF%81")
  • [⑧ 常见权限报错与运行时异常排查](#⑧ 常见权限报错与运行时异常排查 "#%E2%91%A7-%E5%B8%B8%E8%A7%81%E6%9D%83%E9%99%90%E6%8A%A5%E9%94%99%E4%B8%8E%E8%BF%90%E8%A1%8C%E6%97%B6%E5%BC%82%E5%B8%B8%E6%8E%92%E6%9F%A5")
  • [⑨ 不同 Android 版本的兼容性适配技巧](#⑨ 不同 Android 版本的兼容性适配技巧 "#%E2%91%A8-%E4%B8%8D%E5%90%8C-android-%E7%89%88%E6%9C%AC%E7%9A%84%E5%85%BC%E5%AE%B9%E6%80%A7%E9%80%82%E9%85%8D%E6%8A%80%E5%B7%A7")
  • [⑩ 项目优化方向与安全隐私注意事项](#⑩ 项目优化方向与安全隐私注意事项 "#%E2%91%A9-%E9%A1%B9%E7%9B%AE%E4%BC%98%E5%8C%96%E6%96%B9%E5%90%91%E4%B8%8E%E5%AE%89%E5%85%A8%E9%9A%90%E7%A7%81%E6%B3%A8%E6%84%8F%E4%BA%8B%E9%A1%B9")
  • [⑪ 实战代码:完整短信收发模块](#⑪ 实战代码:完整短信收发模块 "#%E2%91%AA-%E5%AE%9E%E6%88%98%E4%BB%A3%E7%A0%81%E5%AE%8C%E6%95%B4%E7%9F%AD%E4%BF%A1%E6%94%B6%E5%8F%91%E6%A8%A1%E5%9D%97")

在移动应用开发中,短信功能往往是被低估却又不可或缺的一环。无论是用于用户身份验证、订单通知,还是构建简易的内部通讯工具,直接调用系统原生短信能力都能带来最稳定的体验。然而,许多开发者在着手实现时,往往会卡在权限申请的繁琐流程上,或者在处理不同 Android 版本的行为差异时感到头疼。特别是随着系统安全策略的收紧,过去简单的配置方式已不再适用,稍有不慎就会导致应用在真机上崩溃或功能失效。

这篇文章将基于实际的开发场景,从零开始梳理一套完整的短信模块实现方案。我们不会只停留在理论层面,而是会深入代码细节,探讨如何优雅地处理权限请求、如何设计符合用户习惯的交互界面,以及如何应对接收短信时的后台监听逻辑。如果你正在为一个项目添加短信收发功能,或者想要重构现有的老旧代码以适配新系统,那么接下来的内容将提供可直接落地的参考路径。我们将避开那些过时的教程陷阱,专注于当前主流开发环境下的最佳实践,确保你的应用既能跑通流程,又能经得起真机测试的考验。

① 开发环境搭建与权限配置要点

启动任何涉及系统底层能力的功能前,环境的正确配置是基石。在现代 Android 开发中,首先需要在 AndroidManifest.xml 文件中声明必要的权限。对于短信模块,核心权限主要包括读取短信 (READ_SMS)、发送短信 (SEND_SMS) 以及接收短信 (RECEIVE_SMS)。值得注意的是,从 Android 6.0 (API Level 23) 开始,这些被归类为"危险权限",仅仅在清单中声明是不够的,必须在运行时动态向用户申请。

在 AndroidManifest.xml 中,我们需要在 <manifest> 根节点下添加如下权限声明:

xml 复制代码
<uses-permission android:name="android.permission.READ_SMS" />
<uses-permission android:name="android.permission.SEND_SMS" />
<uses-permission android:name="android.permission.RECEIVE_SMS" />

其中,READ_SMS 用于读取短信数据库,SEND_SMS 用于应用内静默发送短信,RECEIVE_SMS 用于监听新短信广播。如果你的应用只需要唤起系统短信应用来发送,那么 SEND_SMS 权限可以省略,这能减少权限弹窗对用户的打扰。

除了权限声明,依赖库的选择也至关重要。为了简化兼容性处理,建议引入 androidx.core 库中的 ActivityCompat 和 ContextCompat 类,它们能帮助我们以更统一的方式检查和处理权限状态。在 build.gradle 文件中添加依赖:

gradle 复制代码
dependencies {
    implementation "androidx.core:core-ktx:1.12.0"
    implementation "androidx.appcompat:appcompat:1.6.1"
}

运行时权限的申请逻辑通常放在 Activity 的 onCreate 方法中,先检查权限是否已授予,未授予则发起请求:

java 复制代码
if (ContextCompat.checkSelfPermission(this, Manifest.permission.READ_SMS)
        != PackageManager.PERMISSION_GRANTED) {
    ActivityCompat.requestPermissions(this,
            new String[]{
                    Manifest.permission.READ_SMS,
                    Manifest.permission.SEND_SMS,
                    Manifest.permission.RECEIVE_SMS
            },
            REQUEST_SMS_PERMISSIONS);
} else {
    // 权限已授予,直接加载短信列表
    loadSmsList();
}

同时,若需要监听短信广播,还需注册相应的 BroadcastReceiver,并在清单中配置 intent-filter,确保系统能将收到的短信事件投递给我们的应用。在开发初期,务必确认测试设备的系统版本,因为不同版本的默认短信应用行为可能存在差异,这直接影响后续的调试策略。建议在 build.gradle 中设置 minSdkVersion 为 23(Android 6.0),以覆盖运行时权限模型,同时将 targetSdkVersion 提升到较新版本,确保应用遵循最新的系统安全策略。

② 短信列表界面布局设计与实现

一个友好的短信列表界面是用户体验的第一道门槛。在设计布局时,我们通常采用 RecyclerView 来展示数据,因为它能高效处理大量列表项的复用与滚动。每个列表项(Item Layout)应包含发件人号码、短信内容摘要、接收时间以及阅读状态标识。为了提升可读性,发件人号码若匹配本地联系人,应尽量显示联系人姓名;若无匹配,则显示号码本身。

首先,在 activity_main.xml 中放置一个 RecyclerView 作为列表容器:

xml 复制代码
<androidx.recyclerview.widget.RecyclerView
    android:id="@+id/rvSmsList"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:clipToPadding="false"
    android:paddingBottom="8dp"
    tools:listitem="@layout/item_sms" />

接下来,创建列表项布局 item_sms.xml。使用 ConstraintLayout 来灵活安排各个控件的位置:将发件人信息置于左上角,时间戳置于右上角,内容摘要占据下方主要区域并限制最大行数以避免界面杂乱:

xml 复制代码
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:padding="12dp">

    <TextView
        android:id="@+id/tvSender"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:textStyle="bold"
        android:maxLines="1"
        android:ellipsize="end"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintEnd_toStartOf="@id/tvTime" />

    <TextView
        android:id="@+id/tvTime"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:textSize="12sp"
        android:textColor="#999999"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tvContent"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:maxLines="2"
        android:ellipsize="end"
        android:textColor="#666666"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tvSender"
        app:layout_constraintEnd_toEndOf="parent" />

    <View
        android:id="@+id/viewUnreadDot"
        android:layout_width="8dp"
        android:layout_height="8dp"
        android:background="@drawable/bg_unread_dot"
        android:visibility="gone"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

针对未读短信,可以通过改变背景色或添加醒目的标记点来提示用户。上面的布局中预留了一个 viewUnreadDot 小圆点,配合一个简单的 drawable 即可实现:

xml 复制代码
<!-- res/drawable/bg_unread_dot.xml -->
<shape xmlns:android="http://schemas.android.com/apk/res/android"
    android:shape="oval">
    <solid android:color="#FF5252" />
    <size android:width="8dp" android:height="8dp" />
</shape>

在 Adapter 的实现中,需注意数据绑定的逻辑,确保当数据源更新时,界面能实时刷新。对于长短信的处理,建议在 UI 层做截断显示,并提供点击展开详情的交互,保持列表的整洁性。核心 Adapter 代码如下:

java 复制代码
public class SmsAdapter extends RecyclerView.Adapter<SmsAdapter.SmsViewHolder> {

    private final List<SmsMessage> data = new ArrayList<>();

    public void submitList(List<SmsMessage> newList) {
        data.clear();
        data.addAll(newList);
        notifyDataSetChanged();
    }

    @NonNull
    @Override
    public SmsViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) {
        View view = LayoutInflater.from(parent.getContext())
                .inflate(R.layout.item_sms, parent, false);
        return new SmsViewHolder(view);
    }

    @Override
    public void onBindViewHolder(@NonNull SmsViewHolder holder, int position) {
        SmsMessage sms = data.get(position);
        holder.tvSender.setText(sms.address);
        holder.tvTime.setText(formatTime(sms.date));
        holder.tvContent.setText(sms.body);

        // 未读标记:这里以 type 字段简单示意,实际可按业务状态判断
        boolean unread = sms.type == 1;
        holder.viewUnreadDot.setVisibility(unread ? View.VISIBLE : View.GONE);
    }

    @Override
    public int getItemCount() {
        return data.size();
    }

    private String formatTime(long timestamp) {
        SimpleDateFormat sdf = new SimpleDateFormat("MM-dd HH:mm", Locale.getDefault());
        return sdf.format(new Date(timestamp));
    }

    static class SmsViewHolder extends RecyclerView.ViewHolder {
        TextView tvSender, tvTime, tvContent;
        View viewUnreadDot;

        SmsViewHolder(@NonNull View itemView) {
            super(itemView);
            tvSender = itemView.findViewById(R.id.tvSender);
            tvTime = itemView.findViewById(R.id.tvTime);
            tvContent = itemView.findViewById(R.id.tvContent);
            viewUnreadDot = itemView.findViewById(R.id.viewUnreadDot);
        }
    }
}

最后,在 Activity 中完成 RecyclerView 的初始化与数据绑定:

java 复制代码
RecyclerView rvSmsList = findViewById(R.id.rvSmsList);
rvSmsList.setLayoutManager(new LinearLayoutManager(this));
SmsAdapter adapter = new SmsAdapter();
rvSmsList.setAdapter(adapter);

// 数据加载完成后调用
adapter.submitList(smsList);

这样,一个结构清晰、可复用的短信列表界面就搭建完成了。后续如需增加点击展开详情、长按删除等交互,只需在 SmsViewHolder 中为 itemView 绑定相应监听即可。

③ 读取手机短信数据的代码逻辑

获取短信数据的核心在于查询系统提供的 ContentProvider。Android 系统将所有短信存储在 content://sms/ _uri_下,我们可以通过 ContentResolver 进行查询。代码逻辑上,首先构建一个查询投影(Projection),指定需要获取的字段,如地址 (address)、内容 (body)、日期 (date) 和类型 (type)。

java 复制代码
public List<SmsMessage> loadSmsMessages(Context context) {
    List<SmsMessage> smsList = new ArrayList<>();
    Uri uri = Uri.parse("content://sms/inbox");
    String[] projection = new String[]{"address", "body", "date", "type"};
    
    // 按日期降序排列,确保最新短信在前
    String sortOrder = "date DESC";
    
    Cursor cursor = context.getContentResolver().query(uri, projection, null, null, sortOrder);
    
    if (cursor != null) {
        while (cursor.moveToNext()) {
            String address = cursor.getString(cursor.getColumnIndexOrThrow("address"));
            String body = cursor.getString(cursor.getColumnIndexOrThrow("body"));
            long date = cursor.getLong(cursor.getColumnIndexOrThrow("date"));
            int type = cursor.getInt(cursor.getColumnIndexOrThrow("type"));
            
            smsList.add(new SmsMessage(address, body, date, type));
        }
        cursor.close();
    }
    return smsList;
}

这段代码展示了基本的查询流程。需要注意的是,Cursor 对象使用完毕后必须关闭,以防内存泄漏。另外,查询操作属于 I/O 密集型任务,严禁在主线程中执行,否则会导致 ANR(应用无响应)。应当将其放入协程或异步线程中运行,待数据加载完成后,再通过回调或 LiveData 通知 UI 层进行更新。

下面给出一个基于 ExecutorService 的异步加载示例,避免阻塞主线程:

java 复制代码
public void loadSmsAsync(Context context, SmsLoadCallback callback) {
    ExecutorService executor = Executors.newSingleThreadExecutor();
    executor.execute(() -> {
        List<SmsMessage> result = loadSmsMessages(context);
        new Handler(Looper.getMainLooper()).post(() -> callback.onLoaded(result));
    });
    executor.shutdown();
}

public interface SmsLoadCallback {
    void onLoaded(List<SmsMessage> smsList);
}

对于不同类型的短信(如收件箱、已发送、草稿箱),只需修改 URI 路径即可实现分类读取。常用的 URI 对应关系如下:

短信类型 URI 路径 说明
收件箱 content://sms/inbox 收到的短信
已发送 content://sms/sent 已发送的短信
草稿箱 content://sms/draft 未发送的草稿
全部 content://sms/all 所有短信(含收件、发送、草稿等)

此外,type 字段也对应着固定的枚举值:1 表示收件箱、2 表示已发送、3 表示草稿箱、4 表示已发出、5 表示失败、6 表示队列中。在业务层可以根据 type 值做更精细的过滤与展示,例如只展示收件箱中未读的短信,或统计某段时间内的发送失败记录。

④ 新建短信发送界面的交互设计

发送短信的界面设计应遵循"极简"原则,减少用户的操作步骤。标准界面通常包含三个核心元素:收件人输入框、短信内容编辑区以及发送按钮。收件人输入框最好支持自动补全功能,能够读取本地联系人列表,让用户通过输入姓名或号码片段快速定位目标。

在交互细节上,内容编辑区应具备字数统计功能,实时显示当前输入字符数及剩余可用字数(通常单条短信限制为 160 个英文字符或 70 个中文字符),当超出限制时提示用户短信将被拆分计费。发送按钮在内容为空时应处于禁用状态,防止误触。此外,考虑到用户可能需要进行群发,界面还应支持添加多个收件人,并以标签(Chip)的形式展示在输入框上方,允许用户随时移除某个特定收件人。键盘弹出时,界面应自动调整布局,确保发送按钮始终可见且易于点击。

下面给出一个简洁的发送界面布局示例,使用 LinearLayout 纵向排列,配合 TextInputLayout 提升输入体验:

xml 复制代码
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical"
    android:padding="16dp">

    <!-- 收件人输入框,支持自动补全 -->
    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/tilRecipient"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:hint="收件人">

        <AutoCompleteTextView
            android:id="@+id/etRecipient"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:inputType="phone"
            android:maxLines="1" />
    </com.google.android.material.textfield.TextInputLayout>

    <!-- 短信内容编辑区 -->
    <com.google.android.material.textfield.TextInputLayout
        android:id="@+id/tilContent"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:layout_marginTop="12dp"
        android:hint="短信内容">

        <EditText
            android:id="@+id/etContent"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:gravity="top|start"
            android:inputType="textMultiLine"
            android:maxLength="500" />
    </com.google.android.material.textfield.TextInputLayout>

    <!-- 字数统计与发送按钮 -->
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="8dp"
        android:gravity="center_vertical"
        android:orientation="horizontal">

        <TextView
            android:id="@+id/tvCount"
            android:layout_width="0dp"
            android:layout_height="wrap_content"
            android:layout_weight="1"
            android:text="0/70"
            android:textColor="#999999"
            android:textSize="12sp" />

        <Button
            android:id="@+id/btnSend"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:enabled="false"
            android:text="发送" />
    </LinearLayout>

</LinearLayout>

在代码层面,字数统计与发送按钮的联动逻辑如下。这里以中文字符 70 字为单条上限,超出后提示用户短信将被拆分计费:

java 复制代码
EditText etContent = findViewById(R.id.etContent);
TextView tvCount = findViewById(R.id.tvCount);
Button btnSend = findViewById(R.id.btnSend);

etContent.addTextChangedListener(new TextWatcher() {
    @Override
    public void beforeTextChanged(CharSequence s, int start, int count, int after) {
    }

    @Override
    public void onTextChanged(CharSequence s, int start, int before, int count) {
    }

    @Override
    public void afterTextChanged(Editable s) {
        int length = s.length();
        tvCount.setText(length + "/70");

        // 内容为空时禁用发送按钮,防止误触
        btnSend.setEnabled(length > 0);

        // 超过单条上限时提示拆分计费
        if (length > 70) {
            tvCount.setTextColor(getColor(R.color.warning));
            tvCount.setText(length + "/70(将拆分为多条)");
        } else {
            tvCount.setTextColor(getColor(R.color.text_secondary));
        }
    }
});

对于群发场景,可以在收件人输入框上方放置一个 ChipGroup,用于展示已添加的多个收件人。当用户从联系人列表选中某人时,将其号码以 Chip 形式加入,并允许点击删除按钮移除:

java 复制代码
ChipGroup chipGroup = findViewById(R.id.chipGroup);

private void addRecipientChip(String phoneNumber) {
    Chip chip = new Chip(this);
    chip.setText(phoneNumber);
    chip.setCloseIconVisible(true);
    chip.setOnCloseIconClickListener(v -> chipGroup.removeView(chip));
    chipGroup.addView(chip);
}

最后,记得在 AndroidManifest.xml 中为发送界面所在的 Activity 配置 android:windowSoftInputMode="adjustResize",这样当键盘弹出时,系统会自动压缩布局高度,确保发送按钮始终可见,避免被软键盘遮挡。

⑤ 调用系统 API 实现短信发送功能

实现短信发送主要有两种方式:一种是直接调用系统短信应用,另一种是在应用内后台静默发送。出于安全性和用户体验的考虑,目前更推荐第一种方式,即通过 Intent 唤起系统默认短信应用。这种方式无需申请 SEND_SMS 权限,避免了复杂的权限弹窗,同时也让用户有机会在发送前最后确认内容。

方式一:唤起系统短信应用(推荐)

通过 Intent 唤起系统短信应用,核心是构造一个 ACTION_VIEW 的 Intent,并设置 sms: 协议的 URI。如果需要预填内容,可以通过 sms_body 附加字段传入。代码如下:

java 复制代码
private void openSystemSmsApp(String phoneNumber, String message) {
    Intent intent = new Intent(Intent.ACTION_VIEW);
    intent.setData(Uri.parse("sms:" + phoneNumber));
    intent.putExtra("sms_body", message);
    
    // 检查是否有能处理该 Intent 的应用
    if (intent.resolveActivity(getPackageManager()) != null) {
        startActivity(intent);
    } else {
        // 兜底逻辑:提示用户未安装短信应用
        Toast.makeText(this, "未找到可用的短信应用", Toast.LENGTH_SHORT).show();
    }
}

这里有几个细节值得注意:

  • sms: URI 中的号码不需要做 URL 编码,直接拼接即可;但如果号码来自用户输入,建议先做合法性校验,过滤掉空格、括号等非法字符,避免构造出无法解析的 URI。
  • 如果希望同时支持多个收件人,可以使用 sms: 后跟多个以分号分隔的号码,例如 sms:10086;10010,部分系统短信应用会将其识别为群发。
  • 唤起系统应用后,用户可以在系统界面中最后确认收件人和内容,这既降低了误发风险,也符合用户对隐私的预期。

方式二:应用内静默发送(需 SEND_SMS 权限)

如果业务场景确实要求应用内静默发送(例如验证码自动回填后的回执),则必须申请 SEND_SMS 权限,并使用 SmsManager 类。代码如下:

java 复制代码
SmsManager smsManager = SmsManager.getDefault();
ArrayList<String> parts = smsManager.divideMessage(message);
// 发送 multipart 短信以处理长文本
smsManager.sendMultipartTextMessage(phoneNumber, null, parts, null, null);

对于较短的短信,也可以直接使用 sendTextMessage 方法,代码更简洁:

java 复制代码
SmsManager smsManager = SmsManager.getDefault();
smsManager.sendTextMessage(phoneNumber, null, message, null, null);

处理发送结果回调

使用 SmsManager 时需格外小心,务必处理好发送成功和失败的回调广播,以便给用户明确的反馈。sendTextMessage 和 sendMultipartTextMessage 都支持传入 PendingIntent 作为发送状态回调,分别对应"已发送"和"已送达"两个阶段。下面给出一个完整的封装示例:

java 复制代码
private static final String ACTION_SMS_SENT = "com.example.sms.SMS_SENT";
private static final String ACTION_SMS_DELIVERED = "com.example.sms.SMS_DELIVERED";

private void sendSmsWithCallback(String phoneNumber, String message) {
    SmsManager smsManager = SmsManager.getDefault();

    // 发送状态回调:短信已提交给系统(不代表对方已收到)
    PendingIntent sentIntent = PendingIntent.getBroadcast(this, 0,
            new Intent(ACTION_SMS_SENT), PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);

    // 送达状态回调:对方手机已收到(部分运营商/机型可能不支持)
    PendingIntent deliveredIntent = PendingIntent.getBroadcast(this, 1,
            new Intent(ACTION_SMS_DELIVERED), PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);

    // 长短信自动拆分发送
    ArrayList<String> parts = smsManager.divideMessage(message);
    ArrayList<PendingIntent> sentIntents = new ArrayList<>();
    ArrayList<PendingIntent> deliveredIntents = new ArrayList<>();
    for (int i = 0; i < parts.size(); i++) {
        sentIntents.add(sentIntent);
        deliveredIntents.add(deliveredIntent);
    }
    smsManager.sendMultipartTextMessage(phoneNumber, null, parts, sentIntents, deliveredIntents);
}

对应的广播接收器需要在 onReceive 中根据 resultCode 判断发送结果:

java 复制代码
public class SmsSendReceiver extends BroadcastReceiver {

    @Override
    public void onReceive(Context context, Intent intent) {
        String action = intent.getAction();
        if (action == null) {
            return;
        }

        switch (getResultCode()) {
            case Activity.RESULT_OK:
                if (ACTION_SMS_SENT.equals(action)) {
                    Toast.makeText(context, "短信已发送", Toast.LENGTH_SHORT).show();
                } else if (ACTION_SMS_DELIVERED.equals(action)) {
                    Toast.makeText(context, "短信已送达", Toast.LENGTH_SHORT).show();
                }
                break;
            case SmsManager.RESULT_ERROR_GENERIC_FAILURE:
                Toast.makeText(context, "发送失败:未知错误", Toast.LENGTH_SHORT).show();
                break;
            case SmsManager.RESULT_ERROR_NO_SERVICE:
                Toast.makeText(context, "发送失败:无信号服务", Toast.LENGTH_SHORT).show();
                break;
            case SmsManager.RESULT_ERROR_NULL_PDU:
                Toast.makeText(context, "发送失败:PDU 为空", Toast.LENGTH_SHORT).show();
                break;
            case SmsManager.RESULT_ERROR_RADIO_OFF:
                Toast.makeText(context, "发送失败:飞行模式或信号关闭", Toast.LENGTH_SHORT).show();
                break;
            default:
                Toast.makeText(context, "发送失败:错误码 " + getResultCode(), Toast.LENGTH_SHORT).show();
                break;
        }
    }
}

同时,需要在 AndroidManifest.xml 中注册该接收器,并声明 SEND_SMS 权限:

xml 复制代码
<uses-permission android:name="android.permission.SEND_SMS" />

<receiver android:name=".SmsSendReceiver"
    android:exported="false">
    <intent-filter>
        <action android:name="com.example.sms.SMS_SENT" />
        <action android:name="com.example.sms.SMS_DELIVERED" />
    </intent-filter>
</receiver>

注意事项

  • 静默发送属于敏感操作,Google Play 等应用商店对 SEND_SMS 权限的审核非常严格,非核心业务场景建议避免使用,优先走唤起系统应用的方案。
  • 某些定制 ROM 可能会拦截非默认短信应用的发送请求,因此静默发送的成功率并非 100%,务必做好失败兜底和用户提示。
  • 发送状态回调中的"已发送"仅代表短信已提交给系统短信中心,不代表对方已收到;"已送达"回调在部分运营商或机型上可能不会触发,不能作为唯一判断依据。
  • 从 Android 10 开始,非默认短信应用访问短信数据库受限,但通过 SmsManager 发送短信本身不受影响,只要持有 SEND_SMS 权限即可。

⑥ 监听并处理收到短信的回复机制

要实现收到短信后的自动处理或提醒,我们需要注册一个广播接收器 (BroadcastReceiver)。在清单文件中声明接收 android.provider.Telephony.SMS_RECEIVED 动作的过滤器。当系统收到新短信时,会发送有序广播,我们的接收器可以拦截并解析其中的数据。

1. 清单文件注册接收器

首先,在 AndroidManifest.xml 中声明 RECEIVE_SMS 权限,并注册广播接收器。为了让接收器能优先拿到短信广播,可以设置较高的 android:priority:

xml 复制代码
<uses-permission android:name="android.permission.RECEIVE_SMS" />

<receiver android:name=".SmsReceiver"
    android:exported="true"
    android:permission="android.permission.BROADCAST_SMS">
    <intent-filter android:priority="999">
        <action android:name="android.provider.Telephony.SMS_RECEIVED" />
    </intent-filter>
</receiver>

2. 解析 PDU 并提取短信内容

解析逻辑主要涉及从 Intent 的 Bundle 中提取 PDUs (Protocol Data Units)。对于 Android 4.4 及以上版本,还需要注意短信格式的兼容性问题(GSM 与 CDMA)。下面给出一个完整的 SmsReceiver 实现,它负责解析短信、识别验证码并回调给 UI 层:

java 复制代码
public class SmsReceiver extends BroadcastReceiver {

    private static final String SMS_RECEIVED_ACTION = "android.provider.Telephony.SMS_RECEIVED";
    private static final String ACTION_VERIFICATION_RECEIVED = "com.example.sms.VERIFICATION_RECEIVED";

    @Override
    public void onReceive(Context context, Intent intent) {
        if (!SMS_RECEIVED_ACTION.equals(intent.getAction())) {
            return;
        }

        Bundle bundle = intent.getExtras();
        if (bundle == null) {
            return;
        }

        Object[] pdus = (Object[]) bundle.get("pdus");
        if (pdus == null || pdus.length == 0) {
            return;
        }

        StringBuilder fullMessage = new StringBuilder();
        String sender = null;

        // 长短信会被拆分成多条 PDU,需要拼接完整内容
        for (Object pdu : pdus) {
            SmsMessage sms = SmsMessage.createFromPdu((byte[]) pdu);
            if (sender == null) {
                sender = sms.getOriginatingAddress();
            }
            fullMessage.append(sms.getMessageBody());
        }

        String content = fullMessage.toString();
        Log.d("SmsReceiver", "收到短信,发件人: " + sender + ",内容: " + content);

        // 在这里根据业务规则处理收到的短信,例如识别验证码
        if (content.contains("验证码") || content.matches(".*\\d{4,6}.*")) {
            // 匹配到验证码,可回调给 UI 层自动填充
            Intent callback = new Intent(ACTION_VERIFICATION_RECEIVED);
            callback.setPackage(context.getPackageName());
            callback.putExtra("sender", sender);
            callback.putExtra("code", extractCode(content));
            context.sendBroadcast(callback);
        }
    }

    private String extractCode(String content) {
        java.util.regex.Matcher matcher =
                java.util.regex.Pattern.compile("\\d{4,6}").matcher(content);
        return matcher.find() ? matcher.group() : "";
    }
}

3. 在 Activity 中接收验证码回调

在 Activity 中动态注册一个内部接收器,用于接收 SmsReceiver 发出的验证码广播,并自动填充到输入框:

java 复制代码
private final BroadcastReceiver verificationReceiver = new BroadcastReceiver() {
    @Override
    public void onReceive(Context context, Intent intent) {
        String code = intent.getStringExtra("code");
        if (code != null && etCode != null) {
            etCode.setText(code);
            Toast.makeText(MainActivity.this, "验证码已自动填充", Toast.LENGTH_SHORT).show();
        }
    }
};

@Override
protected void onStart() {
    super.onStart();
    IntentFilter filter = new IntentFilter("com.example.sms.VERIFICATION_RECEIVED");
    registerReceiver(verificationReceiver, filter);
}

@Override
protected void onStop() {
    super.onStop();
    unregisterReceiver(verificationReceiver);
}

4. 后台监听与高版本适配

需要注意的是,高版本 Android 对后台广播的限制较多。从 Android 8.0 (Oreo) 开始,系统限制了隐式广播的注册,但 SMS_RECEIVED 属于系统有序广播,静态注册仍然可以触发。不过,当应用处于后台或被杀死时,广播的投递可能被延迟或拦截。为了确保监听的稳定性,可以采取以下策略:

  • 前台服务保活:如果业务对短信监听有强实时性要求(如验证码自动填充),可以启动一个前台服务,在服务中动态注册接收器,获得更高的优先级。
  • WorkManager 兜底 :对于非实时性任务(如统计短信、生成报告),可以结合 WorkManager 做延迟处理,避免长时间占用前台服务。
  • 动态注册优先:在应用处于前台时,优先使用动态注册的接收器;进入后台后再依赖静态注册的接收器,两者互补。

5. 注意事项

  • SmsMessage.createFromPdu() 在 Android 4.4 及以上版本中,建议使用 createFromPdu(byte[] pdu, String format) 重载,并传入 bundle.getString("format") 以正确处理 GSM/CDMA 格式差异。
  • 有序广播中,如果接收器调用了 abortBroadcast(),后续接收器将无法收到该广播,请谨慎使用。
  • 验证码识别正则建议根据实际短信模板调整,避免误匹配普通数字短信。
  • 从 Android 10 开始,非默认短信应用读取短信数据库受限,但通过广播接收器监听新短信不受影响,只要持有 RECEIVE_SMS 权限即可。

⑦ 完整流程联调与真机测试验证

代码编写完成后,联调与真机测试是验证功能稳定性的关键步骤。模拟器在短信功能的模拟上往往存在局限,无法真实反映权限弹窗、系统拦截策略以及多卡设备的行为,因此必须使用真机进行测试。

1. 测试环境准备

在开始联调之前,建议先做好以下准备工作,避免测试过程中反复返工:

  • 准备多台真机:至少准备 2~3 台不同品牌、不同 Android 版本的设备(如一台 Android 6.0 老机型、一台 Android 8.0 主流机型、一台 Android 10+ 新机型),覆盖主流系统版本。
  • 准备测试 SIM 卡:至少准备一张可正常收发短信的 SIM 卡,最好再准备一张用于互发测试的副卡,方便验证收发双向流程。
  • 开启开发者选项:在每台设备上开启「开发者选项」和「USB 调试」,并安装好对应的 USB 驱动。
  • 关闭省电模式:测试后台监听时,务必关闭设备的省电模式或对应用的白名单限制,否则广播可能被系统挂起,导致误判。
  • 准备测试号码:准备一个可接收短信的测试号码(如另一台手机),用于验证发送与接收的完整闭环。

2. 核心测试用例清单

建议按照下面的用例清单逐项验证,覆盖正常、异常与边界三类场景:

测试类别 测试项 预期结果 备注
正常流程 首次启动申请权限 弹出权限弹窗,授权后进入主界面 验证 READ_SMS、SEND_SMS、RECEIVE_SMS 三项
正常流程 读取短信列表 列表正确展示收件箱短信,按时间倒序 检查发件人、内容、时间、未读标记
正常流程 唤起系统应用发送 跳转到系统短信应用,内容预填正确 验证 sms: URI 与 sms_body 参数
正常流程 应用内静默发送 对方能收到短信,回调提示「已发送」 需持有 SEND_SMS 权限
正常流程 接收新短信 应用能收到广播并解析出内容 验证 SMS_RECEIVED 广播触发
异常流程 拒绝权限 应用不崩溃,给出引导提示 验证 onRequestPermissionsResult 分支
异常流程 无 SIM 卡 发送失败并提示「无信号服务」 验证 RESULT_ERROR_NO_SERVICE 分支
异常流程 飞行模式 发送失败并提示「飞行模式或信号关闭」 验证 RESULT_ERROR_RADIO_OFF 分支
边界情况 超长短信(>70 中文字符) 自动拆分为多条发送,对方能收到完整内容 验证 divideMessage 与 sendMultipartTextMessage
边界情况 特殊字符(表情、换行) 内容完整传输,无乱码 验证编码处理
边界情况 群发多个收件人 每个收件人都能收到短信 验证 sms:10086;10010 格式
边界情况 后台/被杀状态接收 应用被杀死后仍能收到广播并触发逻辑 验证静态注册接收器

3. 联调步骤与日志排查

在联调过程中,重点观察 Logcat 日志,捕捉任何潜在的崩溃堆栈或权限异常。建议在关键节点添加日志输出,便于快速定位问题:

java 复制代码
// 在权限申请回调中打印授权结果
@Override
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
    super.onRequestPermissionsResult(requestCode, permissions, grantResults);
    for (int i = 0; i < permissions.length; i++) {
        Log.d("SmsDebug", "权限 " + permissions[i] + " 结果: "
                + (grantResults[i] == PackageManager.PERMISSION_GRANTED ? "已授予" : "被拒绝"));
    }
}

// 在短信接收广播中打印解析结果
Log.d("SmsDebug", "收到短信,发件人: " + sender + ",内容: " + content);

// 在发送回调中打印结果码
Log.d("SmsDebug", "发送结果码: " + getResultCode());

使用 Android Studio 的 Logcat 面板,按以下步骤排查:

  1. 过滤日志 :在 Logcat 中按包名或 SmsDebug 标签过滤,只查看本应用的日志,避免被系统日志淹没。
  2. 观察崩溃堆栈 :如果应用崩溃,Logcat 会输出 FATAL EXCEPTION 堆栈,定位到具体行号即可快速修复。
  3. 验证广播触发 :在真机上向测试号码发送短信,观察 Logcat 是否出现 SmsReceiver 的日志。若未出现,说明广播未触发,需检查清单注册或系统拦截策略。
  4. 检查权限状态 :通过 adb shell pm list permissions -d 或直接在应用内打印权限状态,确认运行时权限是否已正确授予。

4. 后台与多设备专项测试

特别要测试在应用处于后台或被杀死状态下,接收短信广播是否能正常触发。具体操作如下:

  • 后台测试:将应用切到后台(按 Home 键),然后向测试号码发送短信,观察应用是否仍能收到广播并触发逻辑(如验证码自动填充)。
  • 杀死进程测试 :在「最近任务」中划掉应用,或通过 adb shell am force-stop 包名 强制停止应用,再发送短信,验证静态注册的接收器是否仍能触发。
  • 多卡设备测试:在双卡手机上分别用两张卡收发短信,确认应用能正确处理不同 SIM 卡来源的短信。
  • 定制 ROM 测试:在 MIUI、ColorOS、EMUI 等定制系统上重点测试,因为厂商对后台广播和权限的管理策略可能存在差异,必要时引导用户将应用加入电池优化白名单。

5. 常见问题与处理建议

在真机测试中,如果遇到以下问题,可以参考对应的处理思路:

  • 权限弹窗不出现 :检查 targetSdkVersion 是否 ≥ 23,以及是否在 onCreate 中正确调用了 requestPermissions。
  • 广播收不到 :确认清单中 RECEIVE_SMS 权限已声明、接收器已注册且 android:exported="true";检查设备是否开启了省电模式或对应用的后台限制。
  • 发送失败 :查看 Logcat 中的 resultCode,根据错误码(NO_SERVICE、RADIO_OFF 等)判断是信号问题还是权限问题。
  • 读取列表为空 :确认已授予 READ_SMS 权限,且当前应用是默认短信应用(Android 10+ 非默认应用读取受限)。
  • 返回应用状态丢失 :唤起系统短信应用后返回,若界面状态丢失,可在 onResume 中重新加载数据或恢复输入框内容。

通过以上系统化的联调与真机测试,可以最大程度地发现并解决隐藏的兼容性问题,确保短信模块在真实用户设备上稳定运行。

⑧ 常见权限报错与运行时异常排查

在开发过程中,权限问题是导致功能失败的最常见原因。下面按异常类型逐一拆解,并给出对应的排查思路与解决方案,帮助你在真机调试时快速定位问题。

1. SecurityException:权限未授予或未声明

这是短信模块中最典型的异常,通常发生在未获得运行时授权就尝试读取短信、调用 SmsManager 或访问短信数据库时。触发场景包括:

  • 清单文件中未声明 READ_SMS、SEND_SMS、RECEIVE_SMS 权限;
  • 已声明权限但未在运行时动态申请(Android 6.0+ 危险权限必须运行时申请);
  • 用户拒绝了权限弹窗,但代码仍继续执行敏感操作。

排查步骤:

  1. 检查 AndroidManifest.xml 中是否声明了对应权限;
  2. 确认 targetSdkVersion ≥ 23,否则系统不会触发运行时权限弹窗;
  3. 在访问短信资源前,先调用 ContextCompat.checkSelfPermission() 检查权限状态;
  4. 在 onRequestPermissionsResult() 中处理用户拒绝的情况,给出引导提示而非直接崩溃。

解决方案:封装一个统一的权限检查与申请工具类,在每次访问短信资源前强制校验:

java 复制代码
public static boolean ensureSmsPermission(Activity activity) {
    if (ContextCompat.checkSelfPermission(activity, Manifest.permission.READ_SMS)
            != PackageManager.PERMISSION_GRANTED) {
        ActivityCompat.requestPermissions(activity,
                new String[]{Manifest.permission.READ_SMS,
                        Manifest.permission.SEND_SMS,
                        Manifest.permission.RECEIVE_SMS},
                REQUEST_SMS_PERMISSIONS);
        return false;
    }
    return true;
}

2. IllegalArgumentException:URI 格式错误或参数非法

这类异常多见于构造 sms: URI 或查询短信数据库时参数传递不当。典型场景包括:

  • 号码中包含空格、括号、# 等非法字符,导致 Uri.parse("sms:" + phoneNumber) 解析失败;
  • 查询短信数据库时传入的投影(Projection)字段名拼写错误;
  • ContentResolver.query() 的 selection 参数包含非法 SQL 语法。

排查步骤:

  1. 打印构造出的 URI 字符串,检查是否包含非法字符;
  2. 核对投影字段名是否与系统短信数据库一致(address、body、date、type 等);
  3. 检查 selection 参数是否使用了正确的占位符 ? 而非直接拼接字符串。

解决方案:在拼接号码前做合法性校验,过滤掉非法字符:

java 复制代码
private String sanitizePhoneNumber(String phoneNumber) {
    // 过滤掉空格、括号、短横线等非法字符
    return phoneNumber.replaceAll("[\\s()\\-]", "");
}

3. CalledFromWrongThreadException:子线程更新 UI

短信查询属于 I/O 密集型操作,若在主线程执行会导致 ANR;反之,若在子线程中直接更新 UI 组件,则会抛出 CalledFromWrongThreadException。这是新手最容易踩的坑之一。

排查步骤:

  1. 检查是否在 Thread、ExecutorService 或协程中直接调用了 TextView.setText() 等 UI 操作;
  2. 确认数据加载完成后是否通过 runOnUiThread()、Handler 或 LiveData 切回主线程。

解决方案 :使用 runOnUiThread() 或 Handler 将 UI 更新切回主线程:

java 复制代码
new Thread(() -> {
    List<SmsMessage> list = repository.loadInbox(context);
    runOnUiThread(() -> {
        adapter.submitList(list);
        tvStatus.setText("共加载 " + list.size() + " 条短信");
    });
}).start();

4. 广播接收器未触发

如果 SmsReceiver 收不到短信广播,通常不是崩溃异常,而是功能静默失效。常见原因包括:

  • 清单文件中未声明 RECEIVE_SMS 权限;
  • 接收器未注册,或 android:exported 属性设置错误;
  • 设备开启了省电模式,系统挂起了后台广播;
  • 应用被强制停止后,静态注册的接收器无法触发(Android 3.1+ 强制停止后需手动启动一次应用才能恢复)。

排查步骤:

  1. 确认清单中 <receiver> 已正确注册且 android:exported="true";
  2. 在 onReceive() 中打印日志,确认广播是否到达;
  3. 检查设备是否对应用开启了电池优化限制;
  4. 通过 adb shell dumpsys package 包名 查看接收器注册状态。

5. 常见异常速查表

为便于快速对照,下表汇总了短信模块开发中常见的异常类型、触发原因与处理建议:

异常类型 典型触发场景 排查要点 处理建议
SecurityException 未授权读取短信、调用 SmsManager 检查权限声明与运行时申请 封装权限校验工具类,拒绝时引导用户
IllegalArgumentException sms: URI 含非法字符、投影字段错误 打印 URI 与字段名 号码合法性校验,核对字段拼写
CalledFromWrongThreadException 子线程更新 UI 检查线程切换逻辑 用 runOnUiThread() 或 Handler 切回主线程
NullPointerException Cursor 为空、bundle 为 null 判空处理 查询前判空,cursor != null 再遍历
广播未触发 权限缺失、省电模式、强制停止 检查清单注册与系统限制 确认 exported="true",引导加入电池白名单
ActivityNotFoundException 设备无短信应用 检查 resolveActivity() 调用前判空,给出兜底提示

6. 通用排查流程

当遇到难以定位的异常时,建议按以下流程系统排查:

  1. 查看 Logcat:按包名或自定义标签过滤日志,定位崩溃堆栈的具体行号;
  2. 复现问题:在真机上稳定复现,记录触发步骤与系统版本;
  3. 检查权限 :通过 adb shell pm list permissions -d 或应用内打印权限状态;
  4. 验证清单 :确认权限声明、接收器注册、exported 属性均正确;
  5. 排除系统限制:关闭省电模式、将应用加入电池优化白名单后重测;
  6. 多设备验证:在不同品牌、不同 Android 版本上交叉测试,排除定制 ROM 差异。

利用 Android Studio 的 Profiler 工具和详细的日志输出,可以快速定位这些运行时异常的根源。掌握上述排查思路后,绝大多数短信模块的异常都能在几分钟内定位并解决。

⑨ 不同 Android 版本的兼容性适配技巧

Android 生态的碎片化要求我们在开发时必须考虑版本差异。Android 6.0 引入了运行时权限模型,这是第一个分水岭;Android 8.0 (Oreo) 对后台执行限制进行了严格管控,影响了后台监听短信的持久性;而 Android 10 (Q) 及更高版本则进一步限制了应用访问设备标识符和非重置 ID 的能力,并对短信默认应用的权限做了更细致的划分。

针对不同版本,应采用"检测 - 适配"的策略。例如,在 Android 10+ 上,若非默认短信应用,访问短信数据库的权限可能会被完全禁止,此时应降级为仅使用 Intent 唤起系统应用的方式。对于后台广播的限制,可以考虑使用 WorkManager 来处理延迟任务,或者在应用处于前台时动态注册接收器以获得更高的优先级。利用 Build.VERSION.SDK_INT 进行判断,编写分支逻辑,是确保应用在各种系统版本上都能平稳运行的必要手段。

1. 版本差异核心要点

在动手写适配代码之前,先梳理清楚各版本在短信相关能力上的关键差异:

  • Android 6.0 (API 23) :引入运行时权限模型,READ_SMS、SEND_SMS、RECEIVE_SMS 从安装时授权改为运行时动态申请。这是所有短信功能开发必须跨过的第一道门槛。
  • Android 8.0 (API 26) :限制隐式广播注册,但 SMS_RECEIVED 属于系统有序广播,静态注册仍可触发;同时后台执行限制收紧,后台任务需改用 JobScheduler 或前台服务。
  • Android 10 (API 29):非默认短信应用访问短信数据库受限,读取短信需引导用户将应用设为默认短信应用;后台启动 Activity 受限。
  • Android 11+ (API 30+) :包可见性(Package Visibility)机制收紧,查询其他应用需在清单中声明 <queries>;标识符访问进一步受限;仅默认短信应用可完整访问短信数据库。

2. 版本差异对比表

为便于快速对照,下表汇总了 Android 6.0、8.0、10.0 及更高版本在短信相关能力上的关键差异与适配建议:

Android 版本 短信权限模型 后台广播限制 默认短信应用访问 适配策略建议
Android 6.0 (API 23) 引入运行时权限,READ_SMS、SEND_SMS、RECEIVE_SMS 需动态申请 后台广播限制较少,BroadcastReceiver 可较稳定触发 未强制要求默认短信应用,普通应用可读取短信数据库 使用 ActivityCompat.requestPermissions() 动态申请权限;在 onRequestPermissionsResult() 中处理授权结果
Android 8.0 (API 26) 沿用运行时权限模型,权限申请逻辑不变 限制隐式广播注册,静态注册的 SMS_RECEIVED 广播仍可接收,但后台执行受限 仍可访问短信数据库,但系统开始收紧后台行为 静态注册 SMS_RECEIVED 接收器;后台任务改用 JobScheduler 或前台服务,避免被系统挂起
Android 10 (API 29) 权限模型不变,但非默认短信应用访问短信数据库受限 后台启动 Activity 受限,后台广播进一步收紧 非默认短信应用访问短信数据库可能被完全禁止 降级为使用 Intent 唤起系统短信应用发送;读取短信需引导用户设为默认短信应用
Android 11+ (API 30+) 权限模型不变,包可见性、标识符访问进一步收紧 后台广播限制持续加强,需前台服务保障监听 仅默认短信应用可完整访问短信数据库 优先使用 Intent 方案;确需后台监听时结合前台服务 + 动态注册接收器,并处理好包可见性声明

3. 基于 SDK_INT 的分支适配代码

在实际开发中,最常用的手段就是通过 Build.VERSION.SDK_INT 判断当前系统版本,从而执行不同的逻辑分支。下面给出一个完整的适配示例,覆盖读取短信、发送短信与后台监听三个核心场景:

java 复制代码
public class SmsCompat {

    /**
     * 判断当前应用是否为默认短信应用(Android 4.4+ 支持)
     */
    public static boolean isDefaultSmsApp(Context context) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
            return Telephony.Sms.getDefaultSmsPackage(context)
                    .equals(context.getPackageName());
        }
        return true; // Android 4.4 以下无默认短信应用概念
    }

    /**
     * 读取短信列表:Android 10+ 非默认短信应用读取受限
     */
    public static List<SmsMessage> loadInboxCompat(Context context) {
        // Android 10+ 且非默认短信应用时,读取可能被拒绝
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q
                && !isDefaultSmsApp(context)) {
            // 降级处理:提示用户设为默认短信应用,或返回空列表
            return new ArrayList<>();
        }
        // 正常读取逻辑
        return new SmsRepository().loadInbox(context);
    }

    /**
     * 发送短信:优先唤起系统应用,Android 10+ 推荐此方案
     */
    public static void sendSmsCompat(Activity activity, String phone, String message) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
            // Android 10+ 推荐唤起系统短信应用,避免权限与默认应用限制
            SmsSender.openSystemSmsApp(activity, phone, message);
        } else {
            // 低版本可尝试静默发送,但需持有 SEND_SMS 权限
            if (ContextCompat.checkSelfPermission(activity, Manifest.permission.SEND_SMS)
                    == PackageManager.PERMISSION_GRANTED) {
                SmsSender.sendSilently(activity, phone, message);
            } else {
                SmsSender.openSystemSmsApp(activity, phone, message);
            }
        }
    }

    /**
     * 注册短信接收器:Android 8.0+ 需注意隐式广播限制
     */
    public static void registerSmsReceiver(Context context, BroadcastReceiver receiver) {
        IntentFilter filter = new IntentFilter(Telephony.Sms.Intents.SMS_RECEIVED_ACTION);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            // Android 8.0+ 动态注册需指定 RECEIVER_EXPORTED 或 NOT_EXPORTED
            context.registerReceiver(receiver, filter, Context.RECEIVER_EXPORTED);
        } else {
            context.registerReceiver(receiver, filter);
        }
    }
}

4. 包可见性声明(Android 11+)

从 Android 11 (API 30) 开始,系统引入了包可见性机制。如果应用需要查询或与其他应用交互(例如检测系统短信应用是否存在),必须在 AndroidManifest.xml 中声明 <queries> 元素,否则 resolveActivity() 可能返回 null,导致唤起系统短信应用失败:

xml 复制代码
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <queries>
        <!-- 声明需要查询的短信应用,确保 resolveActivity() 能正确返回 -->
        <intent>
            <action android:name="android.intent.action.VIEW" />
            <data android:scheme="sms" />
        </intent>
    </queries>

</manifest>

5. 适配策略总结

综合来看,短信模块的版本适配可以归纳为以下几条核心原则:

  • 权限申请统一走运行时模型 :无论目标版本如何,都使用 ActivityCompat.requestPermissions() 动态申请,并在回调中处理拒绝与"不再询问"的情况。
  • 读取短信优先引导设为默认应用:在 Android 10+ 上,若应用不是默认短信应用,读取短信数据库会被拒绝,应主动检测并引导用户前往系统设置。
  • 发送短信优先唤起系统应用 :Intent 方案无需 SEND_SMS 权限,也规避了默认应用限制,是跨版本最稳妥的发送方式。
  • 后台监听结合前台服务 :Android 8.0+ 后台执行受限,若业务对短信监听有强实时性要求,应使用前台服务 + 动态注册接收器,并处理好 RECEIVER_EXPORTED 标志。
  • 善用 Build.VERSION.SDK_INT 分支 :把版本差异封装成独立的兼容工具类(如上面的 SmsCompat),让业务代码保持简洁,避免到处散落版本判断。

掌握以上适配技巧,你的短信模块就能在从 Android 6.0 到最新版本的各类设备上平稳运行,既不会因权限问题崩溃,也不会因系统限制而静默失效。

⑩ 项目优化方向与安全隐私注意事项

随着功能的完善,优化与安全隐私成为不可忽视的议题。一个能跑通的短信模块只是起点,如何让它更流畅、更健壮、更经得起安全审计,才是决定项目能否长期维护的关键。下面从性能、架构、安全与隐私四个维度展开。

1. 性能优化方向

短信列表的加载与渲染是性能优化的重点。短信数据库可能包含成千上万条记录,若每次进入页面都全量查询,必然造成卡顿。建议从以下几个方面入手:

  • 分页加载 :不要一次性查询全部短信,改用分页(如每页 50 条),配合 RecyclerView 的滚动加载更多,显著降低首屏耗时与内存占用。
  • 缓存机制 :对查询结果做内存缓存(如 LruCache)或磁盘缓存,避免频繁 I/O。短信数据变化不频繁,缓存命中率通常较高。
  • 索引优化 :查询时尽量使用 date、thread_id 等字段作为排序与过滤条件,减少全表扫描。
  • 列表项复用 :确保 RecyclerView 的 ViewHolder 正确复用,避免在 onBindViewHolder 中做耗时操作(如重复创建 SimpleDateFormat),可将其提升为成员变量或使用 ThreadLocal。
  • 图片懒加载 :若列表项包含联系人头像,务必使用 Glide 或 Coil 等库实现懒加载与内存缓存,避免在主线程解码图片。

下面给出一个基于 Paging 3 的分页加载示例,帮助你快速落地:

kotlin 复制代码
// 使用 Paging 3 实现短信列表分页
class SmsPagingSource(private val repository: SmsRepository) :
    PagingSource<Int, SmsMessage>() {

    override suspend fun load(params: LoadParams<Int>): LoadResult<Int, SmsMessage> {
        return try {
            val page = params.key ?: 0
            val pageSize = params.loadSize
            val data = repository.loadInboxPage(page, pageSize)
            LoadResult.Page(
                data = data,
                prevKey = if (page > 0) page - 1 else null,
                nextKey = if (data.isNotEmpty()) page + 1 else null
            )
        } catch (e: Exception) {
            LoadResult.Error(e)
        }
    }
}

2. 架构优化方向

短信功能往往只是应用的一个子模块,若所有逻辑都堆在 Activity 中,代码会迅速腐化。建议采用分层架构,让职责清晰、便于测试:

  • Repository 层:封装所有短信数据源(ContentProvider 查询、发送、接收),对外暴露统一接口,屏蔽底层实现细节。
  • ViewModel 层 :持有 LiveData 或 StateFlow,负责业务状态管理,与 UI 解耦,天然支持配置变更。
  • 工具类拆分 :将权限申请、版本适配、号码校验等通用逻辑抽成独立工具类(如 SmsPermissionHelper、SmsCompat),避免重复代码。
  • 依赖注入 :项目规模较大时,可引入 Hilt 或 Koin 管理依赖,提升可测试性与可维护性。

一个典型的分层结构如下:

text 复制代码
ui/          -> Activity、Fragment、Adapter
viewmodel/   -> SmsViewModel(状态与业务编排)
repository/  -> SmsRepository(数据访问)
util/        -> SmsCompat、SmsPermissionHelper、PhoneNumberUtils

3. 安全注意事项

短信涉及用户高度敏感的通信数据,安全红线不容触碰。以下是必须遵守的底线:

  • 最小权限原则 :只申请业务真正需要的权限。例如仅需发送短信时,优先用 Intent 唤起系统应用,不申请 SEND_SMS;仅需接收验证码时,只申请 RECEIVE_SMS,不要顺带申请 READ_SMS。
  • 敏感数据加密 :若必须本地存储短信内容(如草稿、验证码),禁止明文写入 SharedPreferences 或普通文件,应使用 EncryptedSharedPreferences 或 Keystore 加密。
  • 日志脱敏 :打印日志时不要输出完整短信内容或完整手机号,可只打印前几位与后几位(如 138****1234),防止敏感信息泄露到 Logcat。
  • 传输加密:若短信内容需要上传到服务器(如云端备份),必须走 HTTPS,并对请求体做签名校验,防止中间人攻击。
  • 拒绝外部注入:解析短信内容时,注意对文本做转义处理,避免将短信内容直接拼进 SQL、HTML 或 WebView,防止注入攻击。

下面给出一个使用 EncryptedSharedPreferences 加密存储验证码的示例:

java 复制代码
// 使用 EncryptedSharedPreferences 加密存储敏感短信内容
MasterKey masterKey = new MasterKey.Builder(context)
        .setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
        .build();

SharedPreferences prefs = EncryptedSharedPreferences.create(
        context,
        "sms_secure_prefs",
        masterKey,
        EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
        EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM
);

prefs.edit().putString("last_verification_code", code).apply();

4. 隐私注意事项

隐私合规不仅是道德要求,更是应用上架(尤其是 Google Play)的硬性门槛。需要重点落实以下几点:

  • 隐私政策披露:在隐私政策中明确说明收集了哪些短信数据、用途是什么、存储多久、是否会共享给第三方。用户首次启动时,应提供隐私政策入口。
  • 权限用途说明:申请权限时,向用户清晰解释"为什么需要读取短信""数据如何使用",避免模糊表述引发信任危机。
  • 提供关闭入口:在应用设置页提供权限管理入口,允许用户随时撤销短信相关权限,并同步清理已缓存的短信数据。
  • 数据最小化:只收集业务必需的数据,不批量拉取用户全部短信用于分析;验证码识别后,及时丢弃原始内容。
  • 合规上架 :Google Play 对 READ_SMS、SEND_SMS 等敏感权限审核极严,非核心功能(如默认短信应用、短信备份)基本无法通过。若你的应用不属于这些场景,务必优先使用 Intent 唤起系统应用,避免权限审核被拒。

5. 优化与安全自检清单

为方便对照落地,下表汇总了优化与安全的关键检查项:

维度 检查项 建议做法
性能 短信列表是否分页加载 使用 Paging 3 或手动分页,避免全量查询
性能 查询是否在主线程执行 放入协程或 ExecutorService,避免 ANR
架构 短信逻辑是否集中在 Activity 拆分为 Repository + ViewModel 分层
安全 短信内容是否明文存储 使用 EncryptedSharedPreferences 加密
安全 日志是否输出完整短信/号码 脱敏处理,只保留部分字符
隐私 是否申请了多余权限 遵循最小权限原则,按需申请
隐私 隐私政策是否披露数据用途 明确说明收集范围、用途与存储期限
合规 是否依赖 SEND_SMS 静默发送 优先 Intent 方案,规避上架审核风险

掌握以上优化方向与安全隐私要点,你的短信模块不仅能跑得流畅,更能经得起真机测试、安全审计与上架审核的多重考验。记住:功能完善只是第一步,让用户放心使用才是长久之计。

⑪ 实战代码:完整短信收发模块

前面各节分别讲解了权限配置、界面设计、数据读取、发送与接收监听。下面把这些逻辑整合成一个可直接运行的实战模块,包含权限申请、短信读取、发送以及广播接收四个核心部分,方便你对照落地。

1. 权限申请封装

权限申请是短信模块最容易踩坑的地方。一个健壮的封装不仅要能发起请求,还要妥善处理「用户拒绝」「勾选不再询问」「拒绝后引导去设置页」等常见场景。下面给出一个完整可复用的 SmsPermissionHelper,它支持回调接口,方便在 Activity 中统一处理授权结果:

java 复制代码
public class SmsPermissionHelper {

    private static final int REQUEST_SMS_PERMISSIONS = 100;

    // 短信模块需要的全部权限
    private static final String[] SMS_PERMISSIONS = {
            Manifest.permission.READ_SMS,
            Manifest.permission.SEND_SMS,
            Manifest.permission.RECEIVE_SMS
    };

    /**
     * 检查是否已授予全部短信权限
     */
    public static boolean hasSmsPermissions(Context context) {
        for (String permission : SMS_PERMISSIONS) {
            if (ContextCompat.checkSelfPermission(context, permission)
                    != PackageManager.PERMISSION_GRANTED) {
                return false;
            }
        }
        return true;
    }

    /**
     * 发起权限申请
     */
    public static void requestSmsPermissions(Activity activity) {
        ActivityCompat.requestPermissions(activity, SMS_PERMISSIONS, REQUEST_SMS_PERMISSIONS);
    }

    /**
     * 判断用户是否勾选了「不再询问」
     * 若为 true,应引导用户前往系统设置页手动开启
     */
    public static boolean shouldShowRationale(Activity activity) {
        for (String permission : SMS_PERMISSIONS) {
            if (ActivityCompat.shouldShowRequestPermissionRationale(activity, permission)) {
                return true;
            }
        }
        return false;
    }

    /**
     * 处理授权结果回调,返回是否全部授权成功
     */
    public static boolean handlePermissionResult(int requestCode, int[] grantResults) {
        if (requestCode != REQUEST_SMS_PERMISSIONS) {
            return false;
        }
        if (grantResults == null || grantResults.length == 0) {
            return false;
        }
        for (int result : grantResults) {
            if (result != PackageManager.PERMISSION_GRANTED) {
                return false;
            }
        }
        return true;
    }

    /**
     * 跳转到系统应用详情页,引导用户手动开启权限
     */
    public static void openAppSettings(Activity activity) {
        Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS);
        intent.setData(Uri.parse("package:" + activity.getPackageName()));
        activity.startActivity(intent);
    }
}

在 Activity 中配合回调使用,可以覆盖完整的授权流程:

java 复制代码
public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        if (SmsPermissionHelper.hasSmsPermissions(this)) {
            loadSmsList();
        } else {
            SmsPermissionHelper.requestSmsPermissions(this);
        }
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);

        if (SmsPermissionHelper.handlePermissionResult(requestCode, grantResults)) {
            // 全部授权成功,继续加载短信
            loadSmsList();
            return;
        }

        // 授权失败,区分「可再次申请」与「不再询问」两种场景
        if (SmsPermissionHelper.shouldShowRationale(this)) {
            // 用户拒绝但未勾选「不再询问」,可再次弹窗申请
            Toast.makeText(this, "短信权限是读取/发送短信所必需的", Toast.LENGTH_SHORT).show();
            SmsPermissionHelper.requestSmsPermissions(this);
        } else {
            // 用户勾选了「不再询问」,只能引导去系统设置页
            new AlertDialog.Builder(this)
                    .setTitle("需要短信权限")
                    .setMessage("请在设置中手动开启短信权限,否则短信功能将无法使用")
                    .setPositiveButton("去设置", (dialog, which) ->
                            SmsPermissionHelper.openAppSettings(this))
                    .setNegativeButton("取消", null)
                    .show();
        }
    }

    private void loadSmsList() {
        // 查询属于 I/O 操作,放到子线程执行
        new Thread(() -> {
            List<SmsMessage> list = new SmsRepository().loadInbox(this);
            runOnUiThread(() -> {
                // 将 list 交给 RecyclerView Adapter 渲染
                Toast.makeText(this, "共加载 " + list.size() + " 条短信", Toast.LENGTH_SHORT).show();
            });
        }).start();
    }
}

这里有几个设计要点值得说明:

  • 权限数组统一管理 :把 READ_SMS、SEND_SMS、RECEIVE_SMS 集中放在一个数组里,后续增删权限只需改一处,避免散落各处导致遗漏。
  • 区分「拒绝」与「不再询问」 :shouldShowRequestPermissionRationale() 返回 false 且权限未授予时,说明用户已勾选「不再询问」,此时再弹权限框也不会出现,必须引导去系统设置页。
  • 回调统一收敛 :handlePermissionResult() 把授权结果收敛为布尔值,Activity 只需关心「成功」或「失败」,逻辑更清晰。
  • 跳转设置页兜底 :openAppSettings() 使用 ACTION_APPLICATION_DETAILS_SETTINGS 直达应用详情页,用户可手动开启权限后返回,体验更顺畅。

提示:如果你的应用只需要发送短信(走 Intent 唤起系统应用),可以只申请 SEND_SMS 甚至不申请任何短信权限;只需要接收验证码时,仅申请 RECEIVE_SMS 即可。遵循最小权限原则,既能减少弹窗打扰,也能降低应用商店的审核风险。

2. 短信数据模型与读取

短信数据模型是整个模块的数据基石。除了 address、body、date、type 这四个核心字段外,系统短信表还提供了 thread_id(会话线程 ID)、read(是否已读)、person(联系人 ID)等实用字段。把它们一并纳入模型,能让列表展示、未读标记、会话分组等业务逻辑更顺手:

java 复制代码
public class SmsMessage {
    public final long id;          // 短信唯一 ID
    public final String address;   // 发件人/收件人号码
    public final String body;      // 短信内容
    public final long date;        // 时间戳(毫秒)
    public final int type;         // 类型:1收件箱 2已发送 3草稿 4已发出 5失败 6队列
    public final long threadId;    // 会话线程 ID,用于按会话分组
    public final int read;         // 是否已读:0未读 1已读

    public SmsMessage(long id, String address, String body, long date,
                      int type, long threadId, int read) {
        this.id = id;
        this.address = address;
        this.body = body;
        this.date = date;
        this.type = type;
        this.threadId = threadId;
        this.read = read;
    }
}

SmsRepository 负责统一封装对系统 ContentProvider 的访问。除了收件箱,还应支持按类型分类读取,并对外暴露统一的查询入口。这里把投影字段、排序规则都抽成常量,避免魔法字符串散落各处:

java 复制代码
public class SmsRepository {

    // 查询投影:只取需要的字段,避免全字段扫描
    private static final String[] PROJECTION = {
            "_id", "address", "body", "date", "type", "thread_id", "read"
    };

    // 按时间倒序,最新短信在前
    private static final String SORT_ORDER = "date DESC";

    /**
     * 读取收件箱短信
     */
    public List<SmsMessage> loadInbox(Context context) {
        return query(context, Uri.parse("content://sms/inbox"), null);
    }

    /**
     * 按类型读取短信(1收件箱 2已发送 3草稿 4已发出 5失败 6队列)
     */
    public List<SmsMessage> loadByType(Context context, int type) {
        return query(context, Uri.parse("content://sms/all"),
                "type = ?", new String[]{String.valueOf(type)});
    }

    /**
     * 读取某个会话线程下的全部短信
     */
    public List<SmsMessage> loadByThread(Context context, long threadId) {
        return query(context, Uri.parse("content://sms/all"),
                "thread_id = ?", new String[]{String.valueOf(threadId)});
    }

    /**
     * 核心查询方法:统一处理 Cursor 遍历与资源释放
     */
    private List<SmsMessage> query(Context context, Uri uri,
                                   String selection, String[] selectionArgs) {
        List<SmsMessage> smsList = new ArrayList<>();
        Cursor cursor = null;
        try {
            cursor = context.getContentResolver()
                    .query(uri, PROJECTION, selection, selectionArgs, SORT_ORDER);
            if (cursor != null) {
                while (cursor.moveToNext()) {
                    smsList.add(new SmsMessage(
                            cursor.getLong(cursor.getColumnIndexOrThrow("_id")),
                            cursor.getString(cursor.getColumnIndexOrThrow("address")),
                            cursor.getString(cursor.getColumnIndexOrThrow("body")),
                            cursor.getLong(cursor.getColumnIndexOrThrow("date")),
                            cursor.getInt(cursor.getColumnIndexOrThrow("type")),
                            cursor.getLong(cursor.getColumnIndexOrThrow("thread_id")),
                            cursor.getInt(cursor.getColumnIndexOrThrow("read"))
                    ));
                }
            }
        } catch (Exception e) {
            // 权限未授予或数据库异常时,返回空列表并记录日志
            Log.e("SmsRepository", "query sms failed", e);
        } finally {
            if (cursor != null) {
                cursor.close(); // 必须关闭,防止内存泄漏
            }
        }
        return smsList;
    }
}

查询属于 I/O 密集型操作,严禁在主线程执行。这里再提供一个基于 ExecutorService 的异步封装,配合回调接口,让 Activity 侧调用更简洁:

java 复制代码
public class SmsRepository {

    // ... 上面的同步查询方法 ...

    /**
     * 异步读取收件箱,结果通过回调返回(主线程)
     */
    public void loadInboxAsync(Context context, SmsLoadCallback callback) {
        ExecutorService executor = Executors.newSingleThreadExecutor();
        executor.execute(() -> {
            List<SmsMessage> result = loadInbox(context);
            new Handler(Looper.getMainLooper()).post(() -> callback.onLoaded(result));
        });
        executor.shutdown();
    }

    public interface SmsLoadCallback {
        void onLoaded(List<SmsMessage> smsList);
    }
}

使用示例:

java 复制代码
new SmsRepository().loadInboxAsync(this, smsList -> {
    // 回调已在主线程,可直接更新 RecyclerView
    adapter.submitList(smsList);
});

这里有几个设计要点值得说明:

  • 字段尽量齐全 :把 thread_id、read 一并读出来,后续做会话分组、未读角标时无需再回查数据库,一次查询即可满足列表展示。
  • 查询入口统一收敛 :loadInbox、loadByType、loadByThread 都走同一个私有 query() 方法,Cursor 的遍历、异常处理、资源释放只写一遍,避免重复代码。
  • try-finally 保证释放 :把 cursor.close() 放在 finally 中,即使查询中途抛异常也能确保 Cursor 被关闭,杜绝内存泄漏。
  • 异步回调切回主线程 :loadInboxAsync 内部用 Handler 把结果切回主线程,调用方无需关心线程切换,直接更新 UI 即可。

3. 短信发送封装

发送短信是短信模块的核心动作之一。一个健壮的 SmsSender 封装,不仅要覆盖「唤起系统应用」和「应用内静默发送」两种方式,还应处理好长短信拆分、发送结果回调、权限检查等细节,避免在真机上出现「短信没发出去却毫无提示」的尴尬。下面给出一个完整可复用的封装:

java 复制代码
public class SmsSender {

    // 发送结果回调的 Action,用于区分「已发送」与「送达」两种广播
    private static final String ACTION_SMS_SENT = "com.example.sms.SMS_SENT";
    private static final String ACTION_SMS_DELIVERED = "com.example.sms.SMS_DELIVERED";

    /**
     * 方式一:唤起系统短信应用(推荐,无需 SEND_SMS 权限)
     * 用户可在系统界面中最后确认收件人与内容,降低误发风险。
     */
    public static void openSystemSmsApp(Activity activity, String phoneNumber, String message) {
        Intent intent = new Intent(Intent.ACTION_VIEW);
        intent.setData(Uri.parse("sms:" + phoneNumber));
        intent.putExtra("sms_body", message);
        if (intent.resolveActivity(activity.getPackageManager()) != null) {
            activity.startActivity(intent);
        } else {
            Toast.makeText(activity, "未找到可用的短信应用", Toast.LENGTH_SHORT).show();
        }
    }

    /**
     * 方式二:应用内静默发送(需 SEND_SMS 权限)
     * 自动拆分长短信,并通过 PendingIntent 回调发送结果。
     *
     * @param context  上下文(建议传 Activity,便于处理权限与回调)
     * @param phoneNumber 收件人号码
     * @param message   短信内容
     * @param callback  发送结果回调,可为 null(不关心结果时)
     */
    public static void sendSilently(Context context, String phoneNumber,
                                    String message, SmsSendCallback callback) {
        // 1. 权限检查:未授予 SEND_SMS 时直接回调失败,避免静默失败
        if (ContextCompat.checkSelfPermission(context, Manifest.permission.SEND_SMS)
                != PackageManager.PERMISSION_GRANTED) {
            if (callback != null) {
                callback.onResult(false, "未授予 SEND_SMS 权限");
            }
            return;
        }

        // 2. 构造发送与送达的 PendingIntent,用于接收系统回调
        PendingIntent sentIntent = PendingIntent.getBroadcast(
                context, 0,
                new Intent(ACTION_SMS_SENT).setPackage(context.getPackageName()),
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);

        PendingIntent deliveredIntent = PendingIntent.getBroadcast(
                context, 1,
                new Intent(ACTION_SMS_DELIVERED).setPackage(context.getPackageName()),
                PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);

        // 3. 动态注册结果接收器(在 Activity 中注册更便于更新 UI)
        registerResultReceiver(context, callback);

        // 4. 发送:长短信自动拆分,短短信走 sendTextMessage
        SmsManager smsManager = SmsManager.getDefault();
        if (message.length() > 70) {
            ArrayList<String> parts = smsManager.divideMessage(message);
            smsManager.sendMultipartTextMessage(
                    phoneNumber, null, parts, sentIntent, deliveredIntent);
        } else {
            smsManager.sendTextMessage(
                    phoneNumber, null, message, sentIntent, deliveredIntent);
        }
    }

    /**
     * 动态注册发送结果广播接收器,根据 resultCode 判断发送是否成功。
     */
    private static void registerResultReceiver(Context context, SmsSendCallback callback) {
        BroadcastReceiver receiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context ctx, Intent intent) {
                String action = intent.getAction();
                if (ACTION_SMS_SENT.equals(action)) {
                    boolean success = (getResultCode() == Activity.RESULT_OK);
                    if (callback != null) {
                        callback.onResult(success,
                                success ? "发送成功" : "发送失败,错误码 " + getResultCode());
                    }
                    // 回调完成后注销,避免内存泄漏
                    ctx.unregisterReceiver(this);
                }
            }
        };

        IntentFilter filter = new IntentFilter(ACTION_SMS_SENT);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            // Android 13+ 动态注册广播需指定 RECEIVER_EXPORTED / NOT_EXPORTED
            context.registerReceiver(receiver, filter,
                    Context.RECEIVER_NOT_EXPORTED);
        } else {
            context.registerReceiver(receiver, filter);
        }
    }

    /**
     * 发送结果回调接口
     */
    public interface SmsSendCallback {
        void onResult(boolean success, String message);
    }
}

在 Activity 中配合回调使用,可以给用户明确的发送反馈:

java 复制代码
public void onSendClick(View view) {
    String phone = etRecipient.getText().toString().trim();
    String content = etContent.getText().toString().trim();

    if (phone.isEmpty() || content.isEmpty()) {
        Toast.makeText(this, "请填写收件人和短信内容", Toast.LENGTH_SHORT).show();
        return;
    }

    // 方式一:唤起系统应用(推荐)
    SmsSender.openSystemSmsApp(this, phone, content);

    // 方式二:应用内静默发送(需 SEND_SMS 权限)
    // SmsSender.sendSilently(this, phone, content, (success, msg) ->
    //         Toast.makeText(this, msg, Toast.LENGTH_SHORT).show());
}

这里有几个设计要点值得说明:

  • 权限前置检查 :静默发送前先检查 SEND_SMS 权限,未授予时直接回调失败,避免 SecurityException 崩溃或静默失败。
  • 长短信自动拆分 :内容超过 70 个中文字符时,用 divideMessage() 拆分后走 sendMultipartTextMessage,短短信走 sendTextMessage,兼顾正确性与性能。
  • 发送结果回调 :通过 PendingIntent 接收系统广播,resultCode == RESULT_OK 表示发送成功,否则给出错误码便于排查。
  • 动态注册与注销 :结果接收器在回调完成后立即 unregisterReceiver,避免重复注册导致的内存泄漏。
  • Android 13+ 兼容 :动态注册广播时按 Build.VERSION.SDK_INT 区分 RECEIVER_NOT_EXPORTED,避免高版本系统抛异常。
  • 两种方式按需选择:默认推荐唤起系统应用(无需权限、用户可确认);只有验证码回执等强业务场景才用静默发送,且要提前评估应用商店的审核风险。

4. 短信接收广播接收器

短信接收是整个模块的「入口」之一。一个健壮的 SmsReceiver 不仅要能正确解析 PDU 并提取短信内容,还应处理好长短信拼接、验证码识别、动态注册兼容(Android 13+)、以及避免在 onReceive 中做耗时操作等细节。下面给出一个完整可复用的实现:

java 复制代码
public class SmsReceiver extends BroadcastReceiver {

    private static final String SMS_RECEIVED_ACTION = "android.provider.Telephony.SMS_RECEIVED";
    private static final String ACTION_VERIFICATION = "com.example.sms.VERIFICATION_RECEIVED";

    @Override
    public void onReceive(Context context, Intent intent) {
        // 1. 只处理短信到达广播,其它广播直接忽略
        if (!SMS_RECEIVED_ACTION.equals(intent.getAction())) {
            return;
        }

        // 2. 提取 PDU 数组,解析失败时安全返回
        Bundle bundle = intent.getExtras();
        if (bundle == null) {
            return;
        }

        Object[] pdus = (Object[]) bundle.get("pdus");
        if (pdus == null || pdus.length == 0) {
            return;
        }

        // 3. 逐条解析 PDU,拼接长短信内容,并记录发件人
        StringBuilder fullMessage = new StringBuilder();
        String sender = null;

        for (Object pdu : pdus) {
            SmsMessage sms = SmsMessage.createFromPdu((byte[]) pdu);
            if (sms == null) {
                continue; // 个别 PDU 解析失败时跳过,不中断整条短信
            }
            if (sender == null) {
                sender = sms.getOriginatingAddress();
            }
            fullMessage.append(sms.getMessageBody());
        }

        String content = fullMessage.toString();
        if (content.isEmpty()) {
            return;
        }

        // 4. 识别验证码:优先匹配「验证码」关键词,其次匹配 4-6 位数字
        String code = extractCode(content);
        if (code != null) {
            // 5. 通过应用内广播回调给 UI 层,自动填充验证码输入框
            Intent callback = new Intent(ACTION_VERIFICATION);
            callback.setPackage(context.getPackageName()); // 只发给本应用,避免被外部拦截
            callback.putExtra("sender", sender);
            callback.putExtra("code", code);
            callback.putExtra("content", content);
            context.sendBroadcast(callback);
        }
    }

    /**
     * 提取短信中的验证码。
     * 优先匹配「验证码」关键词附近的数字,其次匹配任意 4-6 位数字。
     * 未匹配到时返回 null,避免误判普通短信。
     */
    private String extractCode(String content) {
        // 策略一:匹配「验证码」关键词后的 4-6 位数字
        java.util.regex.Matcher keywordMatcher =
                java.util.regex.Pattern.compile("验证码[^\\d]{0,6}(\\d{4,6})")
                        .matcher(content);
        if (keywordMatcher.find()) {
            return keywordMatcher.group(1);
        }

        // 策略二:匹配任意 4-6 位数字(作为兜底)
        java.util.regex.Matcher digitMatcher =
                java.util.regex.Pattern.compile("\\d{4,6}").matcher(content);
        if (digitMatcher.find()) {
            return digitMatcher.group();
        }

        return null;
    }
}

在 Activity 中动态注册一个内部接收器,用于接收 SmsReceiver 发出的验证码广播,并自动填充到输入框。这里需要特别注意 Android 13+ 的动态注册兼容处理:

java 复制代码
public class MainActivity extends AppCompatActivity {

    private static final String ACTION_VERIFICATION = "com.example.sms.VERIFICATION_RECEIVED";

    private final BroadcastReceiver verificationReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String code = intent.getStringExtra("code");
            String sender = intent.getStringExtra("sender");
            if (code != null) {
                // 自动填充验证码到输入框
                etCode.setText(code);
                Toast.makeText(MainActivity.this,
                        "收到来自 " + sender + " 的验证码", Toast.LENGTH_SHORT).show();
            }
        }
    };

    @Override
    protected void onStart() {
        super.onStart();
        // 动态注册验证码回调接收器
        IntentFilter filter = new IntentFilter(ACTION_VERIFICATION);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            // Android 13+ 必须显式指定导出标志,否则抛异常
            registerReceiver(verificationReceiver, filter,
                    Context.RECEIVER_NOT_EXPORTED);
        } else {
            registerReceiver(verificationReceiver, filter);
        }
    }

    @Override
    protected void onStop() {
        super.onStop();
        // 页面不可见时注销,避免内存泄漏
        unregisterReceiver(verificationReceiver);
    }
}

这里有几个设计要点值得说明:

  • PDU 解析容错 :createFromPdu() 可能返回 null,逐条解析时做空判断并 continue,避免单条 PDU 异常导致整条短信丢失。
  • 长短信自动拼接 :多段 PDU 依次 append 到 StringBuilder,保证长短信内容完整;发件人只取第一段的 getOriginatingAddress()。
  • 验证码识别策略:优先匹配「验证码」关键词附近的数字,避免把普通短信里的电话号码误判为验证码;匹配不到关键词时再兜底匹配任意 4-6 位数字。
  • 应用内广播限定包名 :回调广播通过 setPackage(context.getPackageName()) 限定只发给本应用,防止被其它应用拦截或伪造。
  • 动态注册与注销配对 :在 onStart 注册、onStop 注销,页面不可见时及时释放,避免内存泄漏。
  • Android 13+ 兼容 :动态注册广播时按 Build.VERSION.SDK_INT 区分 RECEIVER_NOT_EXPORTED,高版本系统强制要求显式声明导出标志,否则运行时会抛 SecurityException。
  • onReceive 不做耗时操作 :广播接收器的 onReceive 运行在主线程,且系统对执行时间有严格限制(约 10 秒),这里只做解析与转发,真正的 UI 更新交给 Activity 侧完成。

5. 在 Activity 中串联完整流程

前面几节分别封装了权限申请、数据读取、发送与接收,这一节把它们在 MainActivity 中串联成一个可运行的完整流程。核心思路是:进入页面先检查权限 → 有权限则加载短信列表 → 点击发送走 SmsSender → 收到验证码自动填充 。下面给出一个完整可复用的 MainActivity:

java 复制代码
public class MainActivity extends AppCompatActivity {

    private static final String ACTION_VERIFICATION = "com.example.sms.VERIFICATION_RECEIVED";

    private SmsRepository repository = new SmsRepository();
    private SmsAdapter adapter = new SmsAdapter();

    // 验证码回调接收器:接收 SmsReceiver 转发的应用内广播
    private final BroadcastReceiver verificationReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            String code = intent.getStringExtra("code");
            String sender = intent.getStringExtra("sender");
            if (code != null) {
                etCode.setText(code);
                Toast.makeText(MainActivity.this,
                        "收到来自 " + sender + " 的验证码", Toast.LENGTH_SHORT).show();
            }
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 1. 初始化列表
        RecyclerView rvSmsList = findViewById(R.id.rvSmsList);
        rvSmsList.setLayoutManager(new LinearLayoutManager(this));
        rvSmsList.setAdapter(adapter);

        // 2. 权限检查:未授予则申请,已授予则直接加载
        if (!SmsPermissionHelper.hasSmsPermissions(this)) {
            SmsPermissionHelper.requestSmsPermissions(this);
        } else {
            loadSmsList();
        }
    }

    @Override
    protected void onStart() {
        super.onStart();
        // 3. 动态注册验证码回调接收器(Android 13+ 需指定导出标志)
        IntentFilter filter = new IntentFilter(ACTION_VERIFICATION);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
            registerReceiver(verificationReceiver, filter,
                    Context.RECEIVER_NOT_EXPORTED);
        } else {
            registerReceiver(verificationReceiver, filter);
        }
    }

    @Override
    protected void onStop() {
        super.onStop();
        // 4. 页面不可见时注销,避免内存泄漏
        unregisterReceiver(verificationReceiver);
    }

    @Override
    public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
        super.onRequestPermissionsResult(requestCode, permissions, grantResults);
        if (SmsPermissionHelper.handlePermissionResult(requestCode, grantResults)) {
            loadSmsList();
        } else {
            Toast.makeText(this, "未授予短信权限,功能不可用", Toast.LENGTH_SHORT).show();
        }
    }

    /**
     * 异步加载短信列表,结果回到主线程后刷新 RecyclerView
     */
    private void loadSmsList() {
        repository.loadInboxAsync(this, smsList -> {
            adapter.submitList(smsList);
            Toast.makeText(this, "共加载 " + smsList.size() + " 条短信",
                    Toast.LENGTH_SHORT).show();
        });
    }

    /**
     * 发送按钮点击:唤起系统短信应用(推荐,无需 SEND_SMS 权限)
     */
    public void onSendClick(View view) {
        String phone = etRecipient.getText().toString().trim();
        String content = etContent.getText().toString().trim();

        if (phone.isEmpty() || content.isEmpty()) {
            Toast.makeText(this, "请填写收件人和短信内容", Toast.LENGTH_SHORT).show();
            return;
        }

        SmsSender.openSystemSmsApp(this, phone, content);
    }

    /**
     * 静默发送示例(需 SEND_SMS 权限,按需启用)
     */
    private void sendSilentlyDemo() {
        SmsSender.sendSilently(this, "10086", "你好,这是一条测试短信",
                (success, msg) -> Toast.makeText(this, msg, Toast.LENGTH_SHORT).show());
    }
}

这里有几个设计要点值得说明:

  • 权限申请与回调闭环 :onCreate 中先检查权限,未授予则申请;onRequestPermissionsResult 中根据结果决定是否加载列表,形成「申请 → 回调 → 加载」的完整闭环。
  • 列表加载异步化 :loadSmsList 走 SmsRepository.loadInboxAsync,内部已切回主线程,Activity 侧无需关心线程切换,直接 adapter.submitList 刷新即可。
  • 验证码自动填充 :verificationReceiver 在 onStart 注册、onStop 注销,与 SmsReceiver 发出的应用内广播配对,收到验证码后自动填入 etCode 输入框。
  • 发送方式按需选择 :默认走 openSystemSmsApp 唤起系统应用(无需权限、用户可确认);静默发送封装在 sendSilentlyDemo 中,按业务需要启用。
  • Android 13+ 兼容 :动态注册广播时按 Build.VERSION.SDK_INT 区分 RECEIVER_NOT_EXPORTED,避免高版本系统抛 SecurityException。
  • 生命周期配对:注册/注销、申请/回调都严格配对,避免内存泄漏与重复注册。

6. 清单文件配置

最后,把前面所有模块在 AndroidManifest.xml 中统一配置起来。一个完整的清单文件需要包含:权限声明、SmsReceiver 广播接收器注册、SmsSendReceiver 发送结果接收器注册,以及各 Activity 的声明。下面给出一个完整可复用的配置:

xml 复制代码
<manifest xmlns:android="http://schemas.android.com/apk/res/android">

    <!-- 1. 权限声明 -->
    <uses-permission android:name="android.permission.READ_SMS" />
    <uses-permission android:name="android.permission.SEND_SMS" />
    <uses-permission android:name="android.permission.RECEIVE_SMS" />

    <application
        android:allowBackup="true"
        android:icon="@mipmap/ic_launcher"
        android:label="@string/app_name"
        android:theme="@style/Theme.AppCompat">

        <!-- 2. 主界面 Activity -->
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:windowSoftInputMode="adjustResize">
            <intent-filter>
                <action android:name="android.intent.action.MAIN" />
                <category android:name="android.intent.category.LAUNCHER" />
            </intent-filter>
        </activity>

        <!-- 3. 短信接收广播接收器(监听新短信) -->
        <receiver
            android:name=".SmsReceiver"
            android:exported="true"
            android:permission="android.permission.BROADCAST_SMS">
            <intent-filter android:priority="999">
                <action android:name="android.provider.Telephony.SMS_RECEIVED" />
            </intent-filter>
        </receiver>

        <!-- 4. 发送结果广播接收器(静默发送时接收系统回调) -->
        <receiver
            android:name=".SmsSendReceiver"
            android:exported="false">
            <intent-filter>
                <action android:name="com.example.sms.SMS_SENT" />
                <action android:name="com.example.sms.SMS_DELIVERED" />
            </intent-filter>
        </receiver>

    </application>

</manifest>

这里有几个配置要点值得说明:

  • 权限按需声明 :READ_SMS 用于读取短信列表,SEND_SMS 用于应用内静默发送,RECEIVE_SMS 用于监听新短信广播。如果只走「唤起系统应用」方案,SEND_SMS 可以省略,减少权限弹窗对用户的打扰。
  • SmsReceiver 的 android:exported="true" :短信广播来自系统,必须允许外部(系统)触发,因此导出标志必须为 true;同时通过 android:permission="android.permission.BROADCAST_SMS" 限制只有系统能投递,防止第三方应用伪造短信广播。
  • android:priority="999" :设置较高优先级,让我们的接收器优先拿到短信广播,便于第一时间处理验证码;但要注意,Android 8.0+ 对隐式广播的静态注册有限制,SMS_RECEIVED 属于豁免广播,可以正常静态注册。
  • SmsSendReceiver 的 android:exported="false" :发送结果回调只发给本应用,无需对外导出,设为 false 更安全,也符合 Android 12+ 对显式导出标志的强制要求。
  • windowSoftInputMode="adjustResize":为发送界面配置该属性,键盘弹出时自动压缩布局,确保发送按钮不被遮挡。
  • android:allowBackup 建议设为 false:短信数据涉及用户隐私,关闭备份可避免敏感数据被云备份,提升安全性。

以上代码把权限申请、短信读取、发送与接收监听整合成了一个完整的可运行模块。你可以直接复制到项目中,替换掉示例中的业务逻辑(如验证码识别规则、列表渲染方式),即可快速跑通一套基础的短信收发功能。记得在真机上测试,并针对不同 Android 版本按第⑨节的适配策略做分支处理。

相关推荐
唐诺1 小时前
5、Android JNI 动态注册中的“签名“详解
android·jni
高晶4 小时前
Android 任务管理器开发实战指南
程序员
zhaoshuzhaoshu4 小时前
Android 系统启动与调试中的日志抓取方式全解析
android
雨声不在5 小时前
android 动画掉帧
android
在多学一点6 小时前
MYSQL第二次作业
android·数据库·mysql
骑着蜗牛撵大象3276 小时前
MySQL 8.0 迁移后 SQL 变慢:执行计划里 3 类被忽略的成本差异
android·数据库·sql·mysql·优化器·执行计划
我叫张土豆7 小时前
Android 14+ 模拟器抓包实录:把 Burp CA 装成系统 CA,让 targetSdk≥34 的 App 乖乖解密
android
booboosui10 小时前
Android 悬浮窗藏不住自己:一个标志位与 140 毫秒
android
传奇开心果10 小时前
【Flutter踩坑排雷】第5课:扩展方法冲突坑|同名扩展引发编译歧义,故障规避手段
android·flutter
楠风测开10 小时前
Web 兼容性 + AI 跨浏览器
程序员