Android 10 適配攻略

相比較去年寫的Android 9適配,此次Android 10的內容有點多。沒想到寫了我整整兩天,吐血中。。。html

準備工做

老規矩,首先將咱們項目中的targetSdkVersion改成 29。java

1.Scoped Storage(分區存儲)

說明

在Android 10以前的版本上,咱們在作文件的操做時都會申請存儲空間的讀寫權限。可是這些權限徹底被濫用,形成的問題就是手機的存儲空間中充斥着大量不明做用的文件,而且應用卸載後它也沒有刪除掉。爲了解決這個問題,Android 10 中引入了Scoped Storage 的概念,經過添加外部存儲訪問限制來實現更好的文件管理。android

首先明確一個概念,外部儲存和內部儲存。git

  • 內部儲存:/data 目錄。通常咱們使用getFilesDir()getCacheDir() 方法獲取本應用的內部儲存路徑,讀寫該路徑下的文件不須要申請儲存空間讀寫權限,且卸載應用時會自動刪除。github

  • 外部儲存:/storage/mnt 目錄。通常咱們使用getExternalStorageDirectory()方法獲取的路徑來存取文件。c#

由於不一樣廠商、系統版本的緣由,因此上述的方法並無一個固定的文件路徑。瞭解了上面的概念,那咱們所說的外部儲存訪問限制,能夠認爲是針對getExternalStorageDirectory()路徑下的文件。具體的規則以下表: 緩存

在這裏插入圖片描述
上圖將外部存儲空間分爲了三部分:

  • 特定目錄(App-specific),使用getExternalFilesDir()getExternalCacheDir()方法訪問。無需權限,且卸載應用時會自動刪除。安全

  • 照片、視頻、音頻這類媒體文件。使用MediaStore 訪問,訪問其餘應用的媒體文件時須要READ_EXTERNAL_STORAGE權限。微信

  • 其餘目錄,使用存儲訪問框架SAF(Storage Access Framwork)app

因此在Android 10上即便你擁有了儲存空間的讀寫權限,也沒法保證能夠正常的進行文件的讀寫操做。

適配

最簡單粗暴的方法就是在AndroidManifest.xml中添加 android:requestLegacyExternalStorage="true"來請求使用舊的存儲模式。

可是我不推薦此方法。由於在下一個版本的Android中,此條配置將會失效,將強制採用外部儲存限制。其實早在Android Q Beta 3以前都是強制的,但爲了給開發者適配的時間纔沒有強制執行。因此若是你不抓住這段時間去適配,那麼今年下半年出了Android 11。。。直接開花~~

若是你已經適配Android 10,這裏有個現象要注意一下

若是應用經過升級安裝,那麼還會使用之前的儲存模式(Legacy View)。只有經過首次安裝或是卸載從新安裝才能啓用新模式(Filtered View)。

因此在適配時,咱們的判斷代碼以下:

// 使用Environment.isExternalStorageLegacy()來檢查APP的運行模式
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && 
        !Environment.isExternalStorageLegacy()) {
    }
複製代碼

這樣的好處是你能夠在用戶升級後,能方便的將用戶的數據移動至應用的特定目錄。不然你只能經過SAF去移動,這樣會很是麻煩。若是你要移動數據注意只適用於Android 10下,因此如今適配反而是一個好時機。固然若是你不須要遷移數據,那適配會更省事。

下面就說說推薦適配方案:

  • 對於應用中涉及的文件操做,修改一下你的文件路徑。

之前咱們習慣使用Environment.getExternalStorageDirectory()方法,那麼如今可使用getExternalFilesDir()方法(包括下載的安裝包這類的文件)。若是是緩存類型文件,能夠放到getExternalCacheDir()路徑下。

或者使用MediaStore,將文件存至對應的媒體類型中(圖片:MediaStore.Images ,視頻:MediaStore.Video,音頻:MediaStore.Audio),不過僅限於多媒體文件。

下面代碼將圖片保存到公共目錄下,返回Uri:

public static Uri createImageUri(Context context) {
        ContentValues values = new ContentValues();
        // 須要指定文件信息時,非必須
        values.put(MediaStore.Images.Media.DESCRIPTION, "This is an image");
        values.put(MediaStore.Images.Media.DISPLAY_NAME, "Image.png");
        values.put(MediaStore.Images.Media.MIME_TYPE, "image/png");
        values.put(MediaStore.Images.Media.TITLE, "Image.png");
        values.put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/test");
        
        return context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values);
    }
複製代碼
  • 對於媒體資源的訪問:好比圖片選擇器這類的場景。沒法直接使用File,而應使用Uri。不然報錯以下:
java.io.FileNotFoundException: open failed: EACCES (Permission denied)
複製代碼

好比我在適配項目中使用的圖片選擇器時,首先修改了Glide 經過加載File的方式顯示圖片。改成加載Uri的方式,不然圖片沒法顯示出來。

Uri的獲取方式仍是使用MediaStore

String id = cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID));

Uri uri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, id);
複製代碼

其次爲了便於不影響以前選擇圖片返回File的邏輯(由於通常都是上傳File,沒有直接上傳Uri的操做),因此我將最終選擇的文件又轉存進了getExternalFilesDir(),主要代碼以下:

File imgFile = this.getExternalFilesDir("image");
    if (!imgFile.exists()){
        imgFile.mkdir();
    }
    try {
        File file = new File(imgFile.getAbsolutePath() + File.separator + 
        	System.currentTimeMillis() + ".jpg");
        // 使用openInputStream(uri)方法獲取字節輸入流
        InputStream fileInputStream = getContentResolver().openInputStream(uri);
        FileOutputStream fileOutputStream = new FileOutputStream(file);
        byte[] buffer = new byte[1024];
        int byteRead;
        while (-1 != (byteRead = fileInputStream.read(buffer))) {
            fileOutputStream.write(buffer, 0, byteRead);
        }
        fileInputStream.close();
        fileOutputStream.flush();
        fileOutputStream.close();
        // 文件可用新路徑 file.getAbsolutePath()
    } catch (Exception e) {
        e.printStackTrace();        
    }
複製代碼
  • 若是你要獲取圖片中的地理位置信息,須要申請ACCESS_MEDIA_LOCATION權限,並使用MediaStore.setRequireOriginal()獲取。下面是官方的示例代碼:
Uri photoUri = Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
		 cursor.getString(idColumnIndex));

    final double[] latLong;

    // 從ExifInterface類獲取位置信息
    photoUri = MediaStore.setRequireOriginal(photoUri);
    InputStream stream = getContentResolver().openInputStream(photoUri);
    if (stream != null) {
        ExifInterface exifInterface = new ExifInterface(stream);
        double[] returnedLatLong = exifInterface.getLatLong();

        // If lat/long is null, fall back to the coordinates (0, 0).
        latLong = returnedLatLong != null ? returnedLatLong : new double[2];

        // Don't reuse the stream associated with the instance of "ExifInterface".
        stream.close();
    } else {
        // Failed to load the stream, so return the coordinates (0, 0).
        latLong = new double[2];
    }
複製代碼

這樣下來,一個圖片選擇器就基本適配完了。

補充

應用在卸載後,會將App-specific目錄下的數據刪除,若是在AndroidManifest.xml中聲明:android:hasFragileUserData="true"用戶能夠選擇是否保留。

對於SAF的使用,能夠查看我以前寫的SAF使用攻略,這裏就不展開說了。

最後這裏有一個介紹Scoped Storage的視頻,推薦觀看

2.權限變化

從6.0開始,基本每次都會有權限方面變更,此次也不例外。(前幾天發佈了Android 11的預覽版,看來也有權限方面的變化。。。單次權限即將到來)

1.在後臺運行時訪問設備位置信息須要權限

Android 10 引入了 ACCESS_BACKGROUND_LOCATION 權限(危險權限)。

<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
複製代碼

該權限容許應用程序在後臺訪問位置。若是請求此權限,則還必須請求ACCESS_FINE_LOCATIONACCESS_COARSE_LOCATION權限。只請求此權限無效果。

在Android 10的設備上,若是你的應用的 targetSdkVersion < 29,則在請求ACCESS_FINE_LOCATIONACCESS_COARSE_LOCATION權限時,系統會自動同時請求ACCESS_BACKGROUND_LOCATION。在請求彈框中,選擇「始終容許」表示贊成後臺獲取位置信息,選擇「僅在應用使用過程當中容許」或"拒絕"選項表示拒絕受權。

若是你的應用的 targetSdkVersion >= 29,則請求ACCESS_FINE_LOCATIONACCESS_COARSE_LOCATION權限表示在前臺時擁有訪問設備位置信息的權。在請求彈框中,選擇「始終容許」表示先後臺均可以獲取位置信息,選擇「僅在應用使用過程當中容許」只表示擁有前臺的權限。

總結一下就是下圖:

在這裏插入圖片描述
其實官方 不推薦你使用申請後臺訪問權的方式,由於這樣的結果無非就是多請求一個權限,那麼這像變動還有什麼意義?申請過多的權限,也會形成用戶的反感。因此官方推薦使用 前臺服務來實現,在前臺服務中獲取位置信息。

  1. 首先在清單中對應的service中添加 android:foregroundServiceType="location"
<service android:name="MyNavigationService" android:foregroundServiceType="location" ... >
        ...
    </service>
複製代碼
  1. 啓動前臺服務前檢查是否具備前臺的訪問權限:
boolean permissionApproved = ActivityCompat.checkSelfPermission(this, 
		Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED;

    if (permissionApproved) {
       // 啓動前臺服務
    } else {
       // 請求前臺訪問位置權限
    }
    
複製代碼

如此一來就能夠在Service中獲取位置信息。

2.一些電話、藍牙和WLAN的API須要精確位置權限

下面列舉了Android 10中必須具備 ACCESS_FINE_LOCATION 權限才能使用類和方法:

電話

  • TelephonyManager
    • getCellLocation()
    • getAllCellInfo()
    • requestNetworkScan()
    • requestCellInfoUpdate()
    • getAvailableNetworks()
    • getServiceState()
  • TelephonyScanManager
    • requestNetworkScan()
  • TelephonyScanManager.NetworkScanCallback
    • onResults()
  • PhoneStateListener
    • onCellLocationChanged()
    • onCellInfoChanged()
    • onServiceStateChanged()

WLAN

  • WifiManager
    • startScan()
    • getScanResults()
    • getConnectionInfo()
    • getConfiguredNetworks()
  • WifiAwareManager
  • WifiP2pManager
  • WifiRttManager

藍牙

  • BluetoothAdapter
    • startDiscovery()
    • startLeScan()
  • BluetoothAdapter.LeScanCallback
  • BluetoothLeScanner
    • startScan()

咱們能夠根據上面提供的具體類和方法,在適配項目中檢查是否有使用到並及時處理。

3.ACCESS_MEDIA_LOCATION

Android 10新增權限,上面有提到,不贅述了。

4.PROCESS_OUTGOING_CALLS

Android 10上該權限已廢棄。

3.後臺啓動 Activity 的限制

簡單解釋就是應用處於後臺時,沒法啓動Activity。好比點開一個應用會進入啓動頁或者廣告頁,通常會有幾秒的延時再跳轉至首頁。若是這期間你退到後臺,那麼你將沒法看到跳轉過程。而在以前的版本中,會強制彈出頁面至前臺。

既然是限制,那麼確定有不受限的狀況,主要有如下幾點:

  • 應用具備可見窗口,例如前臺 Activity。

  • 應用在前臺任務的返回棧中已有的 Activity。

  • 應用在 Recents 上現有任務的返回棧中已有的 Activity。Recents 就是咱們的任務管理列表。

  • 應用收到系統的 PendingIntent 通知。

  • 應用收到它應該在其中啓動界面的系統廣播。示例包括 ACTION_NEW_OUTGOING_CALLSECRET_CODE_ACTION。應用可在廣播發送幾秒鐘後啓動 Activity。

  • 用戶已嚮應用授予 SYSTEM_ALERT_WINDOW 權限,或是在應用權限頁開啓後臺彈出頁面的開關。

由於此項行爲變動適用於在 Android 10 上運行的全部應用,因此這一限制致使最明顯的問題就是點擊推送信息時,有些應用沒法進行正常的跳轉(具體的實現問題致使)。因此針對這類問題,能夠採起PendingIntent的方式,發送通知時使用setContentIntent方法。

固然你也能夠申請相應權限或者白名單:

在這裏插入圖片描述

不過申請白名單這種方法受各類手機廠商所限,很麻煩。感受還不如引導用戶手動開啓權限。。。

對於全屏 intent,注意設置最高優先級和添加USE_FULL_SCREEN_INTENT權限,這是一個普通權限。好比微信來語音或者視頻通話時,彈出的接聽頁面就是使用這一功能。

<uses-permission android:name="android.permission.USE_FULL_SCREEN_INTENT"/>
複製代碼
Intent fullScreenIntent = new Intent(this, CallActivity.class);
    PendingIntent fullScreenPendingIntent = PendingIntent.getActivity(this, 0,
            fullScreenIntent, PendingIntent.FLAG_UPDATE_CURRENT);

    NotificationCompat.Builder notificationBuilder =
            new NotificationCompat.Builder(this, CHANNEL_ID)
        .setSmallIcon(R.drawable.notification_icon)
        .setContentTitle("Incoming call")
        .setContentText("(919) 555-1234")
        .setPriority(NotificationCompat.PRIORITY_HIGH) // <--- 高優先級
        .setCategory(NotificationCompat.CATEGORY_CALL)

        // Use a full-screen intent only for the highest-priority alerts where you
        // have an associated activity that you would like to launch after the user
        // interacts with the notification. Also, if your app targets Android 10
        // or higher, you need to request the USE_FULL_SCREEN_INTENT permission in
        // order for the platform to invoke this notification.
        .setFullScreenIntent(fullScreenPendingIntent, true); // <--- 全屏 intent

    Notification incomingCallNotification = notificationBuilder.build();
複製代碼

注意:在部分手機上,直接設置setPriority無效(或者說以渠道優先級爲準)。因此須要建立通知渠道時將重要性設置爲IMPORTANCE_HIGH

NotificationChannel channel = new NotificationChannel(channelId, "xxx", NotificationManager.IMPORTANCE_HIGH);
複製代碼

後臺啓動 Activity 的限制的目的是爲了減小對用戶操做的中斷。若是你有要彈出的頁面,推薦你先彈出通知,讓用戶本身選擇接下來的操做,而不是一股腦的強制彈出。(若是你的全屏intent都讓用戶反感,那他也能夠關掉你的通知,不至於任你擺佈。)

4.深色主題

Android 10 新增了一個系統級的深色主題(在系統設置中開啓)。雖然深色主題並非強制適配項,可是它能夠帶給用戶更好的體驗:

  • 可大幅減小耗電量。 OLED 屏幕中每一個像素都是自主發光,因此在顯示深色元素時像素所消耗的電流更低,尤爲在純黑顏色時像素點能夠徹底關閉來達到省電的效果。

  • 爲弱視以及對強光敏感的用戶提升可視性。深色能夠下降屏幕的總體視覺亮度,減小對眼睛的視覺壓力。

  • 讓全部人均可以在光線較暗的環境中更輕鬆地使用設備。

適配方法有兩種:

1.手動適配(資源替換)

官方文檔中提到的繼承Theme.AppCompat.DayNight 或者 Theme.MaterialComponents.DayNight的方法,但這只是將咱們使用的各類View的默認樣式進行了適配,並不太適用於實際項目的適配。由於具體的項目中的View都按照設計的風格進行了重定義。

其實適配的方法很簡單,相似屏幕適配、國際化的操做,並不須要繼承上面的主題。好比你要修改顏色,就在res 下新建 values-night目錄,建立對應的colors.xml文件。將具體要修改的色值定義在裏面。圖標之類的也是一個思路,建立對應的 drawable-night目錄。

只要你以前的代碼不是硬編碼且代碼規範,那麼適配起來仍是很輕鬆。

2.自動適配(Force Dark)

Android 10 提供 Force Dark 功能。一如其名,此功能可以讓開發者快速實現深色主題背景,而無需明確設置 DayNight 主題背景。

若是您的應用採用淺色主題背景,則 Force Dark 會分析應用的每一個視圖,並在相應視圖在屏幕上顯示以前,自動應用深色主題背景。有些開發者會混合使用 Force Dark 和本機實現,以縮短實現深色主題背景所需的時間。

應用必須選擇啓用 Force Dark,方法是在其主題背景中設置 android:forceDarkAllowed="true"。此屬性會在全部系統及 AndroidX 提供的淺色主題背景(例如 Theme.Material.Light)上設置。使用 Force Dark 時,您應確保全面測試應用,並根據須要排除視圖。

若是您的應用使用Dark Theme主題(例如Theme.Material),則系統不會應用 Force Dark。一樣,若是應用的主題背景繼承自 DayNight 主題(例如Theme.AppCompat.DayNight),則系統不會應用 Force Dark,由於會自動切換主題背景。

您能夠經過 android:forceDarkAllowed 佈局屬性或 setForceDarkAllowed(boolean) 在特定視圖上控制 Force Dark。

上述內容我直接照搬文檔的說明。總結一下,使用Force Dark須要注意幾點:

  • 若是使用的是 DayNightDark Theme 主題,則設置forceDarkAllowed 不生效。

  • 若是有須要排除適配的部分,能夠在對應的View上設置forceDarkAllowed爲false。

這裏說說我實際使用此方法的感覺:總體仍是不錯的,設置的色值會自動取反。但也所以顏色不受控制,可否達到預期效果是個須要注意的問題。追求快速適配能夠採起此方案。


手動切換主題

使用 AppCompatDelegate.setDefaultNightMode(@NightMode int mode)方法,其中參數mode有如下幾種:

  • 淺色 - MODE_NIGHT_NO
  • 深色 - MODE_NIGHT_YES
  • 由省電模式設置 - MODE_NIGHT_AUTO_BATTERY
  • 系統默認 - MODE_NIGHT_FOLLOW_SYSTEM

下面的代碼是官方Demo中的使用示例:

public class ThemeHelper {

    public static final String LIGHT_MODE = "light";
    public static final String DARK_MODE = "dark";
    public static final String DEFAULT_MODE = "default";

    public static void applyTheme(@NonNull String themePref) {
        switch (themePref) {
            case LIGHT_MODE: {
                AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
                break;
            }
            case DARK_MODE: {
                AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
                break;
            }
            default: {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                    AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM);
                } else {
                    AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_AUTO_BATTERY);
                }
                break;
            }
        }
    }
}
複製代碼

經過AppCompatDelegate.getDefaultNightMode()方法,能夠獲取到當前的模式,這樣便於代碼中去適配。

監聽深色主題是否開啓

首先在清單文件中給對應的Activity配置 android:configChanges="uiMode"

<activity android:name=".MyActivity" android:configChanges="uiMode" />
複製代碼

這樣在onConfigurationChanged方法中就能夠獲取:

@Override
    public void onConfigurationChanged(@NonNull Configuration newConfig) {
        super.onConfigurationChanged(newConfig);
        int currentNightMode = newConfig.uiMode & Configuration.UI_MODE_NIGHT_MASK;
        switch (currentNightMode) {
            case Configuration.UI_MODE_NIGHT_NO:
                // 關閉
                break;
            case Configuration.UI_MODE_NIGHT_YES:
                // 開啓
                break;
            default:
                break;    
        }
    }
複製代碼

詳細的內容你能夠參看官方文檔官方Demo

判斷深色主題是否開啓

其實和上面onConfigurationChanged方法同理:

public static boolean isNightMode(Context context) {
        int currentNightMode = context.getResources().getConfiguration().uiMode & 
        	Configuration.UI_MODE_NIGHT_MASK;
        return currentNightMode == Configuration.UI_MODE_NIGHT_YES;
    }

複製代碼

5.標識符和數據

對不可重置的設備標識符實施了限制

受影響的方法包括:

從 Android 10 開始,應用必須具備 READ_PRIVILEGED_PHONE_STATE 特許權限才能正常使用以上這些方法。

若是你的應用沒有該權限,卻仍然使用了以上的方法,則返回的結果會因目標 SDK 版本而異:

  • 若是應用以 Android 10 或更高版本爲目標平臺,則會發生 SecurityException
  • 若是應用以 Android 9(API 級別 28)或更低版本爲目標平臺,則相應方法會返回 null 或佔位符數據(若是應用具備 READ_PHONE_STATE 權限)。不然,會發生 SecurityException

這項改動表示第三方應用沒法獲取Device ID這類惟一標識。若是你須要惟一標識符,請參閱文檔:惟一標識符的最佳作法

固然你也能夠試試移動安全聯盟(MSA)聯合多家廠商共同開發的統一補充設備標識調用SDK。聽說還有點不穩定,由於我暫時尚未嘗試過,因此不作評價。

限制了對剪貼板數據的訪問權限

除非您的應用是默認輸入法 (IME) 或是目前處於焦點的應用,不然它沒法訪問 Android 10 或更高版本平臺上的剪貼板數據。

對啓用和停用 WLAN 實施了限制

以 Android 10 或更高版本爲目標平臺的應用沒法啓用或停用 WLAN。WifiManager.setWifiEnabled()方法始終返回 false。

若是您須要提示用戶啓用或停用 WLAN,請使用設置面板

6.其餘

最後,點贊鼓勵一下~~

參考

相關文章
相關標籤/搜索