1. 程式人生 > 程式設計 >Android 10 適配攻略小結

Android 10 適配攻略小結

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

準備工作

老規矩,首先將我們專案中的 targetSdkVersion 改為 29。

1.Scoped Storage(分割槽儲存) 說明

在Android 10之前的版本上,我們在做檔案的操作時都會申請儲存空間的讀寫許可權。但是這些許可權完全被濫用,造成的問題就是手機的儲存空間中充斥著大量不明作用的檔案,並且應用解除安裝後它也沒有刪除掉。為了解決這個問題,Android 10 中引入了 Scoped Storage 的概念,通過新增外部儲存訪問限制來實現更好的檔案管理。

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

  • 內部儲存: /data 目錄。一般我們使用 getFilesDir()getCacheDir() 方法獲取本應用的內部儲存路徑,讀寫該路徑下的檔案不需要申請儲存空間讀寫許可權,且解除安裝應用時會自動刪除。
  • 外部儲存: /storage/mnt 目錄。一般我們使用 getExternalStorageDirectory() 方法獲取的路徑來存取檔案。

因為不同廠商、系統版本的原因,所以上述的方法並沒有一個固定的檔案路徑。瞭解了上面的概念,那我們所說的外部儲存訪問限制,可以認為是針對 getExternalStorageDirectory() 路徑下的檔案。具體的規則如下表:

Android 10 適配攻略小結

上圖將外部儲存空間分為了三部分:

  • 特定目錄(App-specific),使用 getExternalFilesDir()getExternalCacheDir() 方法訪問。無需許可權,且解除安裝應用時會自動刪除。
  • 照片、視訊、音訊這類媒體檔案。使用 MediaStore 訪問,訪問其他應用的媒體檔案時需要 READ_EXTERNAL_STORAGE 許可權。
  • 其他目錄,使用 儲存訪問框架SAF (Storage Access Framwork)

所以在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,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 許可權表示在前臺時擁有訪問裝置位置資訊的權。在請求彈框中,選擇“始終允許”表示前後臺都可以獲取位置資訊,選擇“僅在應用使用過程中允許”只表示擁有前臺的許可權。

總結一下就是下圖:

Android 10 適配攻略小結

其實官方 不推薦你使用申請後臺訪問權的方式 ,因為這樣的結果無非就是多請求一個許可權,那麼這像變更還有什麼意義?申請過多的許可權,也會造成使用者的反感。所以官方推薦使用 前臺服務

來實現,在前臺服務中獲取位置資訊。

首先在清單中對應的 service 中新增 android:foregroundServiceType="location"

  <service
    android:name="MyNavigationService"
    android:foregroundServiceType="location" ... >
    ...
  </service>

啟動前臺服務前檢查是否具有前臺的訪問許可權:

  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 方法。

當然你也可以申請相應許可權或者白名單:

Android 10 適配攻略小結

不過申請白名單這種方法受各種手機廠商所限,很麻煩。感覺還不如引導使用者手動開啟許可權。。。

對於全屏 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,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.識別符號和資料

對不可重置的裝置識別符號實施了限制

受影響的方法包括:

  • Build
    • getSerial()
  • TelephonyManager
    • getImei()
    • getDeviceId()
    • getMeid()
    • getSimSerialNumber()
    • getSubscriberId()

從 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.其他

Android10上對摺疊屏裝置有了更好的支援,對於有摺疊屏適配的需求,可以參看為可摺疊裝置構建應用 和 華為摺疊屏應用開發指導。

以上內容只是Android 10中比較大的幾項變化,完整的內容可以檢視官方文件。

參考

OPPO - Android Q版本應用相容性適配指導

面向開發者的 Android 10

用阿里巴巴APP的案例,教你如何快速適配「深色模式」

到此這篇關於Android 10 適配攻略小結的文章就介紹到這了,更多相關Android 10 適配內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!