Android 13 正式版适配笔记

1,604 阅读8分钟

Android 13(TIRAMISU)在8月16号正式发布了,又需要进行新一轮的适配了。

每一个新版本的变更中,适配都分为两种,一种是不论开发时是否将targetSdkVersion更改为为最新版,只要App运行在Android 13的手机上都得适配。另一种是开发时将targetSdkVersion更改为最新版本,才需要适配。

官方文档指路,本次的更新核心仍然是隐私和安全,挑一些个人认为重要的记录一下:

1. 针对所有应用

1.1 前台服务(FGS)任务管理

不论App的targetSdkVersion是多少,在Android 13的设备上,用户可以从下拉的通知栏中停止App的前台服务,当用户在下拉的列表中点击停止时,会停止整个应用,而不只是停止那个前台服务。

下图是使用FGS任务管理器停止,与正常的上滑停止,以及在应用的设置中强制停止的对比:

FGS 任务管理器向上滑动强行停止
立即从内存中移除应用
停止媒体播放
停止 FGS/移除关联的通知
移除 activity 返回堆栈
从历史记录中移除应用
取消预定作业
取消闹钟

如果App的前台服务长时间运行(在 24 小时的时间段内至少运行 20 小时),系统会向用户发送通知,详细内容请看文档

这个变更对于有使用前台服务的App来说,需要关注一下。

1.2 改进预加载

Android 13的设备上,系统会尝试确定应用下次启动的时间,并根据该估算值运行预提取作业。应用可以尝试使用预提取作业来实现想要在下次应用启动前完成的任何工作。

通过JobSchedulerApi,可以指定预加载的Job,代码如下:

class PreloadJobService : JobService() {
    override fun onStartJob(params: JobParameters?): Boolean {
        //如果您的服务将继续运行为true。 false表示此作业已完成其工作。
        //在此做预加载
        return false
    }

    override fun onStopJob(params: JobParameters?): Boolean {
        //true向 JobManager 表明您是否要根据在创建作业时提供的重试标准重新安排此作业;
        //或false完全结束工作。无论返回的值如何,您的作业都必须停止执行。
        return false
    }
}

val jobScheduler = getSystemService(Context.JOB_SCHEDULER_SERVICE) as JobScheduler
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
    //此id必须保证唯一性
    val jobId = 1
    val jobInfo = JobInfo.Builder(jobId, ComponentName(packageName, PreloadJobService::class.java.name)).setPrefetch(true).build()
    jobScheduler.schedule(jobInfo)
}

1.3 电池利用率

Android 13(API 级别 33)引入了以下省电措施:

  • 更新了有关系统何时将您的应用放入“受限”应用待机模式存储分区的规则。使用如下api可以查看当前App属于那个存储分区
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP_MR1) {
    val usageStatsManager = getSystemService(Context.USAGE_STATS_SERVICE) as UsageStatsManager
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
            val appStandbyBucket = usageStatsManager.appStandbyBucket
            if (appStandbyBucket > UsageStatsManager.STANDBY_BUCKET_ACTIVE) {
                //应用可能受限
            }
        }
}
  • 对于您的应用在以下情况下可以执行的操作制定了新限制:用户因您应用的后台电池用量过高而将其置于“受限”状态。
  • 新增了系统通知,用于就长时间运行的前台服务向用户发出警告。

自 Android 9(API 级别 28)起,处于“受限”状态的应用具有以下限制:

  • 无法启动前台服务
  • 现有的前台服务会从前台移除
  • 不会触发闹钟
  • 不会执行作业

当您的应用以 Android 13 为目标平台时,除非应用因其他原因启动,否则系统不会传送以下任何广播:

  • BOOT_COMPLETED
  • LOCKED_BOOT_COMPLETED

这一部分的适配,个人感觉就是在App运行时检查一下当前的存储区域,然后提示用户去系统的电池用量中更改App的等级。

1.4 新的通知权限

Android 13引入了新的通知运行时权限POST_NOTIFICATIONS

权限对话框在不同targeSdkVersion出现时间的对比:

  • 如果您的应用以 Android 13(33)或更高版本为目标平台,应用将可以完全自行控制权限对话框的显示时间。您可以借此机会向用户说明应用需要此权限的原因,进而鼓励他们授予该权限。
  • 如果您的应用以 12L(32)或更低版本为目标平台,在您创建通知渠道后您的应用首次启动 activity 时,或在您的应用启动一个 activity,然后创建它的第一个通知渠道时,系统会显示该权限对话框。这通常是在应用启动时。

App在用户同意了该权限之后才能发送通知。

如果targetSdkVersion为33可以通过如下代码来申请权限:

//在Manifest中添加该权限
<manifest ...>
     <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> 
    <application ...>
        ...
    </application>
</manifest>

class ExampleActivity : AppCompatActivity() {

    private lateinit var binding: LayoutExampleActivityBinding

    private val requestSinglePermissionLauncher = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted: Boolean ->
        if (granted) {
            //同意授权
        } else {
            //未同意授权
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                if (!shouldShowRequestPermissionRationale(requestPermissionName)) {
                    //用户拒绝权限并且系统不再弹出请求权限的弹窗
                    //这时需要我们自己处理,比如自定义弹窗告知用户为何必须要申请这个权限
                }
            }
        }
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = DataBindingUtil.setContentView(this, R.layout.layout_example_activity)

        binding.btnRequestPermission.setOnClickListener {
            val notificationManager = NotificationManagerCompat.from(this)
            val areNotificationsEnabled = notificationManager.areNotificationsEnabled()
            if (!areNotificationsEnabled) {
                //向用户说明为何使用通知,申请权限
                requestSinglePermissionLauncher.launch(Manifest.permission.POST_NOTIFICATIONS)
            }
        }
    }
}

1.5 从剪贴板中隐藏敏感内容

如果App允许用户将敏感内容(例如密码或信用卡信息)复制到剪贴板,则必须在ClipData 的 ClipDescription 添加一个标志。添加此标志可阻止敏感内容出现在内容预览中。

示例代码如下:

val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText("phoneNumber", "13000000000")
clipData.apply {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
        description.extras = PersistableBundle().apply {
            putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, true)
        }
    } else {
        description.extras = PersistableBundle().apply {
            putBoolean("android.content.extra.IS_SENSITIVE", true)
        }
    }
}
clipboardManager.setPrimaryClip(clipData)

设置IS_SENSITIVE与否,效果如图:

1661051890828.png

1.5 停止使用用户共享ID

如果App之前有使用已废弃的 android:sharedUserId 属性,并且不再依赖于该属性的功能,您可以将 android:sharedUserMaxSdkVersion 属性设置为 32

<manifest ...>
    android:sharedUserId="SHARED_PACKAGE_NAME"
    android:sharedUserMaxSdkVersion="32" 
    ...
</manifest>

注意不能直接删除android:sharedUserId,会导致App无法更新。

2. 针对targetSdkVersion为33的应用

2.1 针对附近Wi-Fi设备的新的运行时权限

在Android 13之前,应用需要向用户申请定位权限ACCESS_FINE_LOCATION才能完成与热点、Wi-Fi直连,Wi-Fi RTT等功能,用户可能比较难将Wi-Fi与定位权限关联起来。

因此Android 13在NEARBY_DEVICES 权限组中引入了一个新的运行时权限NEARBY_WIFI_DEVICES,用于管理设备通过Wi-Fi与附近接入点的连接。在Android 13以后,如果应用在使用Wi-Fi API时不获取定位信息,则可以只申请该权限NEARBY_WIFI_DEVICES

下列API,必须有NEARBY_WIFI_DEVICES权限才能使用:

2.2 媒体权限细化

如果您的应用以 Android 13 为目标平台,通过下列权限来替代 READ_EXTERNAL_STORAGE

媒体类型请求权限
图片和照片READ_MEDIA_IMAGES
视频READ_MEDIA_VIDEO
音频文件READ_MEDIA_AUDIO

如果用户之前向App授予了 READ_EXTERNAL_STORAGE 权限,系统会自动向App授予所有新权限。如果App之前没有被授予权限,当应用请求上表中显示的任意一个权限时,系统会显示面向用户的对话框。

如果App只需要获取图片、照片和视频,可以直接使用新的照片选择器,不用申请对应的权限。

2.3 后台使用传感器的新权限

如果App的targetSdkVersion,并且在后台运行时需要访问身体传感器信息,那么除了现有的 BODY_SENSORS 权限外,还必须声明新的 BODY_SENSORS_BACKGROUND 权限。

这是受到“硬性限制”的权限,除非设备的安装程序针对您的应用将该权限列入了许可名单,否则您的应用将无法获得此权限。

2.4 intent 过滤器会屏蔽不匹配的 intent

当App向以targetSdkVersion高于33的其他App的导出组件(例如Activity)发送 intent 时,仅当该 intent 与接收应用中的 <intent-filter> 元素匹配时,系统才会传送该 intent。不匹配的 intent 会被屏蔽。

不强制要求匹配 intent 的例外情况包括:

  • 传送到未声明任何 intent 过滤器的组件中的 intent。
  • 源自同一应用内的 intent。
  • 源自系统的 intent;也就是说,从“系统 UID”(uid=1000) 发送的 intent。系统应用包括 system_server 和将 android:sharedUserId 设置为 android.uid.system 的应用。
  • 源自根的 intent。

如果接收方App的targetSdkVersion高于33,仅当 intent 与其声明的 <intent-filter> 元素匹配时,其他App的所有 intent 才会传送到导出组件,而不考虑发送App的targetSdkVersion。

我这边做了个实验,代码大致如下:

//A应用,targetSdkVersion为31
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"">
    <application>
        <activity
            android:name="com.aaa.aaa.AActivity"
            android:exported="true">

            <intent-filter>
                <action android:name="test_jump_app_A" />  
            </intent-filter>
        </activity>
    </application>
</manifest>

class AActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding.jumpB1.setOnClickListener {
            try {
                val intent = Intent()
                intent.setClassName("com.bbb.bbb", "com.bbb.bbb.BActivity")
                intent.action = "test_jump_app_B"
                startActivity(intent)
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
        binding.jumpB2.setOnClickListener {
            try {
                val intent = Intent()
                intent.setClassName("com.bbb.bbb", "com.bbb.bbb.BActivity")
                startActivity(intent)
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }
}

//B应用,targetSdkVersion为33
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"">
    <application>
        <activity
            android:name="com.bbb.bbb.BActivity"
            android:exported="true">

            <intent-filter>
                <action android:name="test_jump_app_B" />  
            </intent-filter>
        </activity>
    </application>
</manifest>

class BActivity : Activity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding.jumpA1.setOnClickListener {
            try {
                val intent = Intent()
                intent.setClassName("com.aaa.aaa", "com.aaa.aaa.AActivity")
                intent.action = "test_jump_app_A"
                startActivity(intent)
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
        binding.jumpA2.setOnClickListener {
            try {
                val intent = Intent()
                intent.setClassName("com.aaa.aaa", "com.aaa.aaa.AActivity")
                startActivity(intent)
            } catch (e: Exception) {
                e.printStackTrace()
            }
        }
    }
}

根据文档描述,AActivity的jumpB1可以成功,AActivity的jumpB2不成功,BActivity的jumpA1A2都成功。

然而实验结果是jumpB1B2,jumpA1A2全部都成功了,与期望的效果不符合。