Android 10 适配及具体解决办法

9,276 阅读6分钟

​ 对Android Q进行适配大家一定要参考Google官方文档, 下面是我在做Android Q所做更改的地方:

分区存储

Android Q新加了沙盒模式, 每个应用只能访问自己过滤视图下的文件夹, 即 sdcard/Android/data/packagename

说明 : Android Q 会继续使用 READ_EXTERNAL_STORAGEWRITE_EXTERNAL_STORAGE 权限, 只有targetSdkVersion>=29, 才会默认启用过滤视图, 并且此时无需申请权限, 即可读写沙盒文件, 需要将文件保存到相册等, 依然需要申请permission.WRITE_EXTERNAL_STORAGE; 如需读写应用以外的文件需要通过存储访问框架

解决方法:

  1. targetSdkVersion<29

  2. 选择停用过滤视图

        <manifest ... >
          <!-- This attribute is "false" by default on apps targeting Android Q. -->
          <application android:requestLegacyExternalStorage="true" ... >
            ...
          </application>
        </manifest>
        
    
  3. 推荐方法, 将文件存储到过滤视图中, 此时也不需要申请权限, 但是应用卸载会把文件删除

    kotlin:

    //图片文件
    val file = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES)
    
    //还有以下那么多环境, 根据存储文件类型选择不同环境
    var DIRECTORY_ALARMS = "Alarms"
    var DIRECTORY_AUDIOBOOKS = "Audiobooks"
    var DIRECTORY_DCIM = "DCIM"
    var DIRECTORY_DOCUMENTS = "Documents"
    var DIRECTORY_DOWNLOADS = "Download"
    var DIRECTORY_MOVIES = "Movies"
    var DIRECTORY_MUSIC = "Music"
    var DIRECTORY_NOTIFICATIONS = "Notifications"
    var DIRECTORY_PICTURES = "Pictures"
    var DIRECTORY_PODCASTS = "Podcasts"
    var DIRECTORY_RINGTONES = "Ringtones"
    var DIRECTORY_SCREENSHOTS = "Screenshots"
    

    java:

    //图片文件
    File file = context.getExternalFilesDir(Environment.DIRECTORY_PICTURES);
    
    //还有以下那么多环境, 根据存储文件类型选择不同环境
    public static String DIRECTORY_ALARMS = "Alarms";
    public static String DIRECTORY_AUDIOBOOKS = "Audiobooks";
    public static String DIRECTORY_DCIM = "DCIM";
    public static String DIRECTORY_DOCUMENTS = "Documents";
    public static String DIRECTORY_DOWNLOADS = "Download";
    public static String DIRECTORY_MOVIES = "Movies";
    public static String DIRECTORY_MUSIC = "Music";
    public static String DIRECTORY_NOTIFICATIONS = "Notifications";
    public static String DIRECTORY_PICTURES = "Pictures";
    public static String DIRECTORY_PODCASTS = "Podcasts";
    public static String DIRECTORY_RINGTONES = "Ringtones";
    public static String DIRECTORY_SCREENSHOTS = "Screenshots";
    

数据和标识符变更

说明: 从 Android Q 开始,应用必须具有 READ_PRIVILEGED_PHONE_STATE 特许权限才能访问设备的不可重置标识符(包含 IMEI 和序列号, 并且这个权限只有系统app才可以使用, 也就是说在Android Q上已经不能获取DeviceId了

替代方法:

  1. Android Id:

    kotlin:

     val androidId = Settings.Secure.getString(
                    context.contentResolver,
                    Settings.Secure.ANDROID_ID
                )
    

    java:

    String androidId = Settings.Secure.getString(
                    context.contentResolver,
                    Settings.Secure.ANDROID_ID
                );
    
  2. 但是在实际应用中发现, 有Android Id获取失败的情况, 所以就完善了上面的方法

    kotlin:

     var deviceId = Settings.Secure.getString(
                    getAppContext().contentResolver,
                    Settings.Secure.ANDROID_ID
                )
      if (androidId.isNullOrEmpty()) {
         deviceId = getUniquePsuedoID()
      }
    
      fun getUniquePsuedoID(): String {
          val devIDShort =
                "35" + Build.BOARD.length % 10 + Build.BRAND.length % 10 + Build.CPU_ABI.length % 10 + Build.DEVICE.length % 10 + Build.MANUFACTURER.length % 10 + Build.MODEL.length % 10 + Build.PRODUCT.length % 10
    
            // API >= 9 的设备才有 android.os.Build.SERIAL
            // http://developer.android.com/reference/android/os/Build.html#SERIAL
            // 如果用户更新了系统或 root 了他们的设备,该 API 将会产生重复记录
            var serial: String?
            try {
                serial = android.os.Build::class.java.getField("SERIAL").get(null).toString()
                return UUID(
                    devIDShort.hashCode().toLong(),
                    serial.hashCode().toLong()
                ).toString()
            } catch (exception: Exception) {
                serial = "serial"
            }
            // 最后,组合上述值并生成 UUID 作为唯一 ID
            return UUID(devIDShort.hashCode().toLong(), serial!!.hashCode().toLong()).toString()
      }
    

    java:

    String deviceId = Settings.Secure.getString(
        context.contentResolver,
        Settings.Secure.ANDROID_ID
                );
    if(TextUtils.isEmpty(deviceId)) {
    	deviceId = getUniquePsuedoID()
    }
    
    public String getUniquePsuedoID() {
        String devIDShort =
            "35" + Build.BOARD.length % 10 + Build.BRAND.length % 10 + Build.CPU_ABI.length % 10 + Build.DEVICE.length % 10 + Build.MANUFACTURER.length % 10 + Build.MODEL.length % 10 + Build.PRODUCT.length % 10;
    
        // API >= 9 的设备才有 android.os.Build.SERIAL
        // http://developer.android.com/reference/android/os/Build.html#SERIAL
        // 如果用户更新了系统或 root 了他们的设备,该 API 将会产生重复记录
        String serial;
        try {
            serial = android.os.Build::class.java.getField("SERIAL").get(null).toString()
                return UUID(
                devIDShort.hashCode().toLong(),
                serial.hashCode().toLong()
            ).toString();
        } catch (Exception e) {
            serial = "serial";
        }
        // 最后,组合上述值并生成 UUID 作为唯一 ID
        return UUID((long)devIDShort.hashCode(), (long)serial.hashCode()).toString();
    }
    

限制Activity后台启动

说明: 此项行为变更适用于在 Android Q 上运行的所有应用,甚至包括以 Android 9(API 级别 28)或更低版本为目标平台的应用。此外,即使您的应用以 Android 9 或更低版本为目标平台并且最初安装在运行 Android 9 或更低版本的设备上,该行为变更仍会在设备升级到 Android Q 后生效。

解决方法:

发送全屏通知会自动启动Activity

kotlin:

    fun sendNotification(
        title: String?,
        body: String?,
        data: PushMessageNode?,
        bitmap: Bitmap?
    ) {
        val intent = Intent(this, PushJumpActivity::class.java)
        intent.putExtra(WhatConstants.Intent.FIRE_PUSH_MESSAGE, data)
        intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
        val pendingIntent = PendingIntent.getActivity(
            this, requestCode, intent,
            PendingIntent.FLAG_UPDATE_CURRENT
        )

        val notificationManager =
            this.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager?

        if (notificationManager != null) {
            val notificationBuilder = NotificationCompat.Builder(this, NOTIFICATION_CHANNEL_ID)
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                val mNotificationChannel =
                    NotificationChannel(
                        NOTIFICATION_CHANNEL_ID,
                        NOTIFICATION_CHANNEL_NAME,
                        NotificationManager.IMPORTANCE_HIGH
                    )

                notificationManager.createNotificationChannel(mNotificationChannel)
            }

            notificationBuilder
                .setSmallIcon(R.mipmap.logo)
                .setLargeIcon(
                    bitmap ?: BitmapFactory.decodeResource(
                        context,
                        R.mipmap.logo
                    )
                )
                .setContentTitle(title)
                .setContentText(body)
                .setShowWhen(true)
                .setAutoCancel(true)
                .setSound(RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION))
            	//设置为全屏通知, 此时若App处于前台, 会为悬挂通知, 无论前台后台, 都会自动启动Acitivity
            	.setFullScreenIntent(pendingIntent, true)
                .setContentIntent(pendingIntent)

            notificationManager.notify(
                requestCode /* ID of notification */,
                notificationBuilder.build()
            )

            bitmap?.recycle()
        }
    }

java:

    private void sendNotification(String title, String body, PushMessageNode data, Bitmap bitmap) {
        Intent intent = new Intent(this, PushJumpActivity.class);
        intent.putExtra(WhatConstants.Intent.INSTANCE.getFIRE_PUSH_MESSAGE(), data);
        intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP);
        int requestCode = (int) (Math.random() * 1000) + 1;
        PendingIntent pendingIntent = PendingIntent.getActivity(this, requestCode /* Request code */, intent,
                PendingIntent.FLAG_UPDATE_CURRENT);

        Uri defaultSoundUri = RingtoneManager.getDefaultUri(RingtoneManager.TYPE_NOTIFICATION);
        NotificationManager notificationManager = null;
        notificationManager = (NotificationManager) this.getSystemService(Context.NOTIFICATION_SERVICE);
        Notification.Builder notificationBuilder;
        if (notificationManager != null) {
            if (Build.VERSION.SDK_INT >= 26) {
                NotificationChannel mNotificationChannel = new NotificationChannel("1", "Channel1", NotificationManager.IMPORTANCE_HIGH);
                notificationManager.createNotificationChannel(mNotificationChannel);
                notificationBuilder = new Notification.Builder(this, "1");
            } else {
                notificationBuilder = new Notification.Builder(this);
            }

            notificationBuilder = notificationBuilder
                    .setSmallIcon(R.mipmap.logo)
                    .setLargeIcon(bitmap != null ? bitmap : BitmapFactory.decodeResource(context.getResources(), R.mipmap.logo))
                    .setContentTitle(title)
                    .setContentText(body)
                    .setShowWhen(true)
                    .setPriority(Notification.PRIORITY_HIGH)
                    .setAutoCancel(true)
                    .setSound(defaultSoundUri)
                	//设置为全屏通知, 此时若App处于前台, 会为悬挂通知, 无论前台后台, 都会自动启动Acitivity
                	.setFullScreenIntent(pendingIntent, true);
                    .setContentIntent(pendingIntent);
  

            notificationManager.notify(requestCode /* ID of notification */, notificationBuilder.build());

            if (bitmap != null)
                bitmap.recycle();
        }
    }

获取剪贴板数据

说明: 只有默认输入法(IME)或者是目前处于焦点的应用, 才能访问到剪贴板数据.

这也就是说应用已经不能在后台监听剪贴板数据了, 不过我对目前处于焦点的应用这句话不太了解 . 另外在适配过程中, 遇到了一个问题, 在Acitivity onCreate直接获取剪贴板数据是不能成功获取的, 而在按钮点击的时候是可以的:

class SimpleActivity: AppCompatActivity() {
    
	override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)

     	//直接获取剪切板数据
        getTextFromClip()
        //剪贴板有数据也return ""

        //点击按钮获取剪切板数据
        view.setOnClickListener {
            getClipboardData()
            //返回剪贴板的正常数据
        }

    }
    
    private fun getTextFromClip(): String {
        val clipboardManager =
        context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
        if (null == clipboardManager || !clipboardManager.hasPrimaryClip()) {
            return ""
        }
        val clipData = clipboardManager.primaryClip
        if (null == clipData || clipData.itemCount < 1) {
            return ""
        }
        val clipText = clipData.getItemAt(0)?.text ?: ""
        return clipText.toString()
    }
}

后面又对目前处于焦点的应用思考了一下, 应该就是视图加载到窗口上才能获取焦点, 后面经过适配, 在view.post()之后获取剪贴板数据,又参考了这篇文章[Android源码解析]view.post()到底干了啥, 了解到view.post()是在view dispatchAttachedToWindow后执行的, 然后写出方法如下:

kotlin:

    /**
     * 获取剪贴板的内容
     */
    fun getClipBoardText(@Nullable activity: Activity?, f: (String) -> Unit) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && activity != null) {
            getTextFromClipFroAndroidQ(activity, f)
        } else {
            f.invoke(getTextFromClip())
        }
    }

    /**
     * AndroidQ 获取剪贴板的内容
     */
    @TargetApi(Build.VERSION_CODES.Q)
    private fun getTextFromClipFroAndroidQ(@NonNull activity: Activity, f: (String) -> Unit) {
        val runnable = Runnable {
            try {
                val clipboardManager =
                    activity.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
                if (null == clipboardManager || !clipboardManager.hasPrimaryClip()) {
                    f.invoke("")
                    return@Runnable
                }
                val clipData = clipboardManager.primaryClip
                if (null == clipData || clipData.itemCount < 1) {
                    f.invoke("")
                    return@Runnable
                }
                val clipText = clipData.getItemAt(0)?.text ?: ""
                f.invoke(clipText.toString())
                return@Runnable
            } catch (e: Exception) {
                f.invoke("")
                return@Runnable
            }
        }
        activity.registerActivityLifecycleCallbacks(object :Application.ActivityLifecycleCallbacks {
            override fun onActivityPaused(activity: Activity) {
            }

            override fun onActivityStarted(activity: Activity) {
            }

            override fun onActivityDestroyed(activity: Activity) {
                activity.window?.decorView?.removeCallbacks(runnable)
            }

            override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {
            }

            override fun onActivityStopped(activity: Activity) {
            }

            override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) {
            }

            override fun onActivityResumed(activity: Activity) {
            }
        })
        activity.window?.decorView?.post(runnable) ?: f.invoke("")
    }

    private fun getTextFromClip(): String {
        try {
			//可以使用Application的Context
            val clipboardManager =
                context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager?
            if (null == clipboardManager || !clipboardManager.hasPrimaryClip()) {
                return ""
            }
            val clipData = clipboardManager.primaryClip
            if (null == clipData || clipData.itemCount < 1) {
                return ""
            }
            val item = clipData.getItemAt(0) ?: return ""
            val clipText = item.text ?: ""
            return if (TextUtils.isEmpty(clipText)) "" else clipText.toString()
        } catch (e: Exception) {
            return ""
        }
    }

java:

    public interface Function {
        /** Invokes the function. */
        void invoke(String text);
    }

    void getClipBoardText(@Nullable Activity activity, final Function f) {
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && activity != null) {
            getTextFromClipFroAndroidQ(activity, f);
        } else {
            f.invoke(getTextFromClip());
        }
    }
    
   /**
     * AndroidQ 获取剪贴板的内容
     */
    @TargetApi(Build.VERSION_CODES.Q)
    private void getTextFroClipFromAndroidQ(@NonNull final Activity activity, final Function f) {
        Runnable runnable = new Runnable() {
            @Override
            public void run() {
                ClipboardManager clipboardManager =
                        (ClipboardManager)activity.getSystemService(Context.CLIPBOARD_SERVICE);
                if (null == clipboardManager || !clipboardManager.hasPrimaryClip()) {
                    f.invoke("");
                    return;
                }
                ClipData clipData = clipboardManager.getPrimaryClip();
                if (null == clipData || clipData.getItemCount() < 1) {
                    f.invoke("");
                    return;
                }
                ClipData.Item item = clipData.getItemAt(0);
                if (item == null) {
                    f.invoke("");
                    return;
                }
                CharSequence clipText = item.getText();
                if (TextUtils.isEmpty(clipText))
                    f.invoke("");
                else
                    f.invoke(clipText.toString());
            }
        }
        activity.registerActivityLifecycleCallbacks(new Application.ActivityLifecycleCallbacks() {
            @Override
            public void onActivityCreated(@NonNull Activity activity, @androidx.annotation.Nullable Bundle savedInstanceState) {
                
            }

            @Override
            public void onActivityStarted(@NonNull Activity activity) {

            }

            @Override
            public void onActivityResumed(@NonNull Activity activity) {

            }

            @Override
            public void onActivityPaused(@NonNull Activity activity) {

            }

            @Override
            public void onActivityStopped(@NonNull Activity activity) {

            }

            @Override
            public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {

            }

            @Override
            public void onActivityDestroyed(@NonNull Activity activity) {
                activity.getWindow().getDecorView().removeCallbacks(runnable);
            }
        });
        activity.getWindow().getDecorView().post(runnable);
    }

    private String getTextFromClip() {
            ClipboardManager clipboardManager =
                    (ClipboardManager)context.getSystemService(Context.CLIPBOARD_SERVICE);
            if (null == clipboardManager || !clipboardManager.hasPrimaryClip()) {
                return "";
            }
            ClipData clipData = clipboardManager.getPrimaryClip();
            if (null == clipData || clipData.getItemCount() < 1) {
                return "";
            }
            ClipData.Item item = clipData.getItemAt(0);
            if (item == null)
                return "";
            CharSequence clipText = item.getText();
            if (TextUtils.isEmpty(clipText))
                return "";
            else
                return clipText.toString();

    }

后台应用获取用户位置权限

说明: AndroidQAndroid Q 引入了新的位置权限 ACCESS_BACKGROUND_LOCATION, 需要申请新权限才能后台访问位置, 前台获取位置权限与以前保持一致, 因为适配无需后台获取用户位置, 所以没有写, 可以参考Android Q 隐私权变更:用户可控制应用对设备位置信息的访问权限

解决办法:

 <manifest>
      <uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
     //新增后台请求位置权限
      <uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
    </manifest>

总结:

以上就是我在适配Android Q所更改的地方, 就是对于获取剪贴板数据的地方, 具有焦点的应用, 不是那么明确, 有了解的大佬来解释一下, 另有错误欢迎指正.