來自:CSDN,作者:唯鹿
https://weilu.blog.csdn.net/article/details/10451317
相比較去年的寫的Android 9適配,這次Android 10的內容有點多。沒想到寫了我整整兩天,吐血中。。。
老規矩,首先將我們項目中的targetSdkVersion改爲 29。
1、Scoped Storage(分區存儲)
說明
在Android 10之前的版本上,我們在做文件的操作時都會申請存儲空間的讀寫權限。但是這些權限完全被濫用,造成的問題就是手機的存儲空間中充斥着大量不明作用的文件,並且應用卸載後它也沒有刪除掉。
爲了解決這個問題,Android 10 中引入了Scoped Storage 的概念,通過添加外部存儲訪問限制來實現更好的文件管理。
首先明確一個概念,外部儲存和內部儲存。
內部儲存:/data 目錄。一般我們使用getFilesDir() 或 getCacheDir() 方法獲取本應用的內部儲存路徑,讀寫該路徑下的文件不需要申請儲存空間讀寫權限,且卸載應用時會自動刪除。
外部儲存:/storage 或 /mnt 目錄。一般我們使用getExternalStorageDirectory()方法獲取的路徑來存取文件。
因爲不同廠商、系統版本的原因,所以上述的方法並沒有一個固定的文件路徑。瞭解了上面的概念,那我們所說的外部儲存訪問限制,可以認爲是針對getExternalStorageDirectory()路徑下的文件。
具體的規則如下表:
上圖將外部存儲空間分爲了三部分:
特定目錄(App-specific),使用getExternalFilesDir()或 getExternalCacheDir()方法訪問。無需權限,且卸載應用時會自動刪除。
照片、視頻、音頻這類媒體文件。使用MediaStore 訪問,訪問其他應用的媒體文件時需要READ_EXTERNAL_STORAGE權限。
其他目錄,使用存儲訪問框架SAF(Storage Access Framwork)
https://developer.android.google.cn/guide/topics/providers/document-provider?hl=zh_cn
所以在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使用攻略,這裏就不展開說了。
https://weilu.blog.csdn.net/article/details/104199446
最後這裏有一個介紹Scoped Storage的視頻,推薦觀看:
https://www.bilibili.com/video/av77198618
2、權限變化
從6.0開始,基本每次都會有權限方面變動,這次也不例外。
(前幾天發佈了Android 11的預覽版,看來也有權限方面的變化。。。單次權限即將到來)
1、在後臺運行時訪問設備位置信息需要權限
Android 10 引入了 ACCESS_BACKGROUND_LOCATION 權限(危險權限)。
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION"/>
該權限允許應用程序在後臺訪問位置。如果請求此權限,則還必須請求ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION權限。只請求此權限無效果。
在Android 10的設備上,如果你的應用的 targetSdkVersion < 29,則在請求ACCESS_FINE_LOCATION 或ACCESS_COARSE_LOCATION權限時,系統會自動同時請求ACCESS_BACKGROUND_LOCATION。
在請求彈框中,選擇“始終允許”表示同意後臺獲取位置信息,選擇“僅在應用使用過程中允許”或"拒絕"選項表示拒絕授權。
如果你的應用的 targetSdkVersion >= 29,則請求ACCESS_FINE_LOCATION 或 ACCESS_COARSE_LOCATION權限表示在前臺時擁有訪問設備位置信息的權。在請求彈框中,選擇“始終允許”表示前後臺都可以獲取位置信息,選擇“僅在應用使用過程中允許”只表示擁有前臺的權限。
總結一下就是下圖:
其實官方不推薦你使用申請後臺訪問權的方式,因爲這樣的結果無非就是多請求一個權限,那麼這像變更還有什麼意義?申請過多的權限,也會造成用戶的反感。所以官方推薦使用前臺服務來實現,在前臺服務中獲取位置信息。
1. 首先在清單中對應的service中添加 android:foregroundServiceType="location":
<service
android:name="MyNavigationService"
android:foregroundServiceType="location" ... >
...
</service>
2. 啓動前臺服務前檢查是否具有前臺的訪問權限:
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_CALL 和 SECRET_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需要注意幾點:
如果使用的是 DayNight 或 Dark 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。
https://developer.android.google.cn/guide/topics/ui/look-and-feel/darktheme
https://github.com/android/user-interface-samples/tree/master/DarkTheme
判斷深色主題是否開啓
其實和上面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這類唯一標識。如果你需要唯一標識符,請參閱文檔:唯一標識符的最佳做法。
https://developer.android.google.cn/training/articles/user-data-ids
當然你也可以試試移動安全聯盟(MSA)聯合多家廠商共同開發的統一補充設備標識調用SDK。據說還有點不穩定,因爲我暫時還沒有嘗試過,所以不做評價。
http://msalliance.icoc.bz/col.jsp?id=120
限制了對剪貼板數據的訪問權限
除非您的應用是默認輸入法 (IME) 或是目前處於焦點的應用,否則它無法訪問 Android 10 或更高版本平臺上的剪貼板數據。
對啓用和停用 WLAN 實施了限制
以 Android 10 或更高版本爲目標平臺的應用無法啓用或停用 WLAN。WifiManager.setWifiEnabled()方法始終返回 false。
如果您需要提示用戶啓用或停用 WLAN,請使用設置面板。
https://developer.android.google.cn/about/versions/10/features#settings-panels
6、其他
Android10上對摺疊屏設備有了更好的支持,對於有摺疊屏適配的需求,可以參看爲可摺疊設備構建應用 和 華爲摺疊屏應用開發指導。
https://developer.android.google.cn/guide/topics/ui/foldables
https://developer.huawei.com/consumer/cn/doc/90101
以上內容只是Android 10中比較大的幾項變化,完整的內容可以查看官方文檔。
https://developer.android.google.cn/about/versions/10/behavior-changes-all