Android 10 适配攻略小结
相比较去年写的Android9适配,这次Android10的内容有点多。没想到写了我整整两天,吐血中。。。
准备工作
老规矩,首先将我们项目中的targetSdkVersion改为29。
1.ScopedStorage(分区存储)说明
在Android10之前的版本上,我们在做文件的操作时都会申请存储空间的读写权限。但是这些权限完全被滥用,造成的问题就是手机的存储空间中充斥着大量不明作用的文件,并且应用卸载后它也没有删除掉。为了解决这个问题,Android10中引入了ScopedStorage的概念,通过添加外部存储访问限制来实现更好的文件管理。
首先明确一个概念,外部储存和内部储存。
- 内部储存:/data目录。一般我们使用getFilesDir()或getCacheDir()方法获取本应用的内部储存路径,读写该路径下的文件不需要申请储存空间读写权限,且卸载应用时会自动删除。
- 外部储存:/storage或/mnt目录。一般我们使用getExternalStorageDirectory()方法获取的路径来存取文件。
因为不同厂商、系统版本的原因,所以上述的方法并没有一个固定的文件路径。了解了上面的概念,那我们所说的外部储存访问限制,可以认为是针对getExternalStorageDirectory()路径下的文件。具体的规则如下表:
上图将外部存储空间分为了三部分:
- 特定目录(App-specific),使用getExternalFilesDir()或getExternalCacheDir()方法访问。无需权限,且卸载应用时会自动删除。
- 照片、视频、音频这类媒体文件。使用MediaStore访问,访问其他应用的媒体文件时需要READ_EXTERNAL_STORAGE权限。
- 其他目录,使用存储访问框架SAF(StorageAccessFramwork)
所以在Android10上即使你拥有了储存空间的读写权限,也无法保证可以正常的进行文件的读写操作。
适配
最简单粗暴的方法就是在AndroidManifest.xml中添加android:requestLegacyExternalStorage="true"来请求使用旧的存储模式。
但是我不推荐此方法。因为在下一个版本的Android中,此条配置将会失效,将强制采用外部储存限制。其实早在AndroidQBeta3之前都是强制的,但为了给开发者适配的时间才没有强制执行。所以如果你不抓住这段时间去适配,那么今年下半年出了Android11。。。直接开花~~
如果你已经适配Android10,这里有个现象要注意一下:
如果应用通过升级安装,那么还会使用以前的储存模式(LegacyView)。只有通过首次安装或是卸载重新安装才能启用新模式(FilteredView)。
所以在适配时,我们的判断代码如下:
//使用Environment.isExternalStorageLegacy()来检查APP的运行模式 if(Build.VERSION.SDK_INT>=Build.VERSION_CODES.Q&& !Environment.isExternalStorageLegacy()){ }
这样的好处是你可以在用户升级后,能方便的将用户的数据移动至应用的特定目录。否则你只能通过SAF去移动,这样会非常麻烦。如果你要移动数据注意只适用于Android10下,所以现在适配反而是一个好时机。当然如果你不需要迁移数据,那适配会更省事。
下面就说说推荐适配方案:
对于应用中涉及的文件操作,修改一下你的文件路径。
以前我们习惯使用Environment.getExternalStorageDirectory()方法,那么现在可以使用getExternalFilesDir()方法(包括下载的安装包这类的文件)。如果是缓存类型文件,可以放到getExternalCacheDir()路径下。
或者使用MediaStore,将文件存至对应的媒体类型中(图片:MediaStore.Images,视频:MediaStore.Video,音频:MediaStore.Audio),不过仅限于多媒体文件。
下面代码将图片保存到公共目录下,返回Uri:
publicstaticUricreateImageUri(Contextcontext){ ContentValuesvalues=newContentValues(); //需要指定文件信息时,非必须 values.put(MediaStore.Images.Media.DESCRIPTION,"Thisisanimage"); 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"); returncontext.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,values); }
对于媒体资源的访问:比如图片选择器这类的场景。无法直接使用File,而应使用Uri。否则报错如下:
java.io.FileNotFoundException:openfailed:EACCES(Permissiondenied)
比如我在适配项目中使用的图片选择器时,首先修改了Glide通过加载File的方式显示图片。改为加载Uri的方式,否则图片无法显示出来。
Uri的获取方式还是使用MediaStore:
Stringid=cursor.getString(cursor.getColumnIndexOrThrow(MediaStore.Images.Media._ID)); Uriuri=Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,id);
其次为了便于不影响之前选择图片返回File的逻辑(因为一般都是上传File,没有直接上传Uri的操作),所以我将最终选择的文件又转存进了getExternalFilesDir(),主要代码如下:
FileimgFile=this.getExternalFilesDir("image"); if(!imgFile.exists()){ imgFile.mkdir(); } try{ Filefile=newFile(imgFile.getAbsolutePath()+File.separator+ System.currentTimeMillis()+".jpg"); //使用openInputStream(uri)方法获取字节输入流 InputStreamfileInputStream=getContentResolver().openInputStream(uri); FileOutputStreamfileOutputStream=newFileOutputStream(file); byte[]buffer=newbyte[1024]; intbyteRead; while(-1!=(byteRead=fileInputStream.read(buffer))){ fileOutputStream.write(buffer,0,byteRead); } fileInputStream.close(); fileOutputStream.flush(); fileOutputStream.close(); //文件可用新路径file.getAbsolutePath() }catch(Exceptione){ e.printStackTrace(); }
如果你要获取图片中的地理位置信息,需要申请ACCESS_MEDIA_LOCATION权限,并使用MediaStore.setRequireOriginal()获取。下面是官方的示例代码:
UriphotoUri=Uri.withAppendedPath(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, cursor.getString(idColumnIndex)); finaldouble[]latLong; //从ExifInterface类获取位置信息 photoUri=MediaStore.setRequireOriginal(photoUri); InputStreamstream=getContentResolver().openInputStream(photoUri); if(stream!=null){ ExifInterfaceexifInterface=newExifInterface(stream); double[]returnedLatLong=exifInterface.getLatLong(); //Iflat/longisnull,fallbacktothecoordinates(0,0). latLong=returnedLatLong!=null?returnedLatLong:newdouble[2]; //Don'treusethestreamassociatedwiththeinstanceof"ExifInterface". stream.close(); }else{ //Failedtoloadthestream,soreturnthecoordinates(0,0). latLong=newdouble[2]; }
这样下来,一个图片选择器就基本适配完了。
补充
应用在卸载后,会将App-specific目录下的数据删除,如果在AndroidManifest.xml中声明:android:hasFragileUserData="true"用户可以选择是否保留。
对于SAF的使用,可以查看我之前写的SAF使用攻略,这里就不展开说了。
最后这里有一个介绍ScopedStorage的视频,推荐观看:
2.权限变化
从6.0开始,基本每次都会有权限方面变动,这次也不例外。(前几天发布了Android11的预览版,看来也有权限方面的变化。。。单次权限即将到来)
1.在后台运行时访问设备位置信息需要权限
Android10引入了ACCESS_BACKGROUND_LOCATION权限(危险权限)。
该权限允许应用程序在后台访问位置。如果请求此权限,则还必须请求ACCESS_FINE_LOCATION或ACCESS_COARSE_LOCATION权限。只请求此权限无效果。
在Android10的设备上,如果你的应用的targetSdkVersion<29,则在请求ACCESS_FINE_LOCATION或ACCESS_COARSE_LOCATION权限时,系统会自动同时请求ACCESS_BACKGROUND_LOCATION。在请求弹框中,选择“始终允许”表示同意后台获取位置信息,选择“仅在应用使用过程中允许”或"拒绝"选项表示拒绝授权。
如果你的应用的targetSdkVersion>=29,则请求ACCESS_FINE_LOCATION或ACCESS_COARSE_LOCATION权限表示在前台时拥有访问设备位置信息的权。在请求弹框中,选择“始终允许”表示前后台都可以获取位置信息,选择“仅在应用使用过程中允许”只表示拥有前台的权限。
总结一下就是下图:
来实现,在前台服务中获取位置信息。
首先在清单中对应的service中添加android:foregroundServiceType="location":
...
启动前台服务前检查是否具有前台的访问权限:
booleanpermissionApproved=ActivityCompat.checkSelfPermission(this, Manifest.permission.ACCESS_COARSE_LOCATION)==PackageManager.PERMISSION_GRANTED; if(permissionApproved){ //启动前台服务 }else{ //请求前台访问位置权限 }
如此一来就可以在Service中获取位置信息。
2.一些电话、蓝牙和WLAN的API需要精确位置权限
下面列举了Android10中必须具有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
Android10新增权限,上面有提到,不赘述了。
4.PROCESS_OUTGOING_CALLS
Android10上该权限已废弃。
3.后台启动Activity的限制
简单解释就是应用处于后台时,无法启动Activity。比如点开一个应用会进入启动页或者广告页,一般会有几秒的延时再跳转至首页。如果这期间你退到后台,那么你将无法看到跳转过程。而在之前的版本中,会强制弹出页面至前台。
既然是限制,那么肯定有不受限的情况,主要有以下几点:
- 应用具有可见窗口,例如前台Activity。
- 应用在前台任务的返回栈中已有的Activity。
- 应用在Recents上现有任务的返回栈中已有的Activity。Recents就是我们的任务管理列表。
- 应用收到系统的PendingIntent通知。
- 应用收到它应该在其中启动界面的系统广播。示例包括ACTION_NEW_OUTGOING_CALL和SECRET_CODE_ACTION。应用可在广播发送几秒钟后启动Activity。
- 用户已向应用授予SYSTEM_ALERT_WINDOW权限,或是在应用权限页开启后台弹出页面的开关。
因为此项行为变更适用于在Android10上运行的所有应用,所以这一限制导致最明显的问题就是点击推送信息时,有些应用无法进行正常的跳转(具体的实现问题导致)。所以针对这类问题,可以采取PendingIntent的方式,发送通知时使用setContentIntent方法。
当然你也可以申请相应权限或者白名单:
不过申请白名单这种方法受各种手机厂商所限,很麻烦。感觉还不如引导用户手动开启权限。。。
对于全屏intent,注意设置最高优先级和添加USE_FULL_SCREEN_INTENT权限,这是一个普通权限。比如微信来语音或者视频通话时,弹出的接听页面就是使用这一功能。
IntentfullScreenIntent=newIntent(this,CallActivity.class); PendingIntentfullScreenPendingIntent=PendingIntent.getActivity(this,0, fullScreenIntent,PendingIntent.FLAG_UPDATE_CURRENT); NotificationCompat.BuildernotificationBuilder= newNotificationCompat.Builder(this,CHANNEL_ID) .setSmallIcon(R.drawable.notification_icon) .setContentTitle("Incomingcall") .setContentText("(919)555-1234") .setPriority(NotificationCompat.PRIORITY_HIGH)//<---高优先级 .setCategory(NotificationCompat.CATEGORY_CALL) //Useafull-screenintentonlyforthehighest-priorityalertswhereyou //haveanassociatedactivitythatyouwouldliketolaunchaftertheuser //interactswiththenotification.Also,ifyourapptargetsAndroid10 //orhigher,youneedtorequesttheUSE_FULL_SCREEN_INTENTpermissionin //orderfortheplatformtoinvokethisnotification. .setFullScreenIntent(fullScreenPendingIntent,true);//<---全屏intent NotificationincomingCallNotification=notificationBuilder.build();
注意:在部分手机上,直接设置setPriority无效(或者说以渠道优先级为准)。所以需要创建通知渠道时将重要性设置为IMPORTANCE_HIGH。
NotificationChannelchannel=newNotificationChannel(channelId,"xxx",NotificationManager.IMPORTANCE_HIGH);
后台启动Activity的限制的目的是为了减少对用户操作的中断。如果你有要弹出的页面,推荐你先弹出通知,让用户自己选择接下来的操作,而不是一股脑的强制弹出。(如果你的全屏intent都让用户反感,那他也可以关掉你的通知,不至于任你摆布。)
4.深色主题
Android10新增了一个系统级的深色主题(在系统设置中开启)。虽然深色主题并不是强制适配项,但是它可以带给用户更好的体验:
- 可大幅减少耗电量。OLED屏幕中每个像素都是自主发光,所以在显示深色元素时像素所消耗的电流更低,尤其在纯黑颜色时像素点可以完全关闭来达到省电的效果。
- 为弱视以及对强光敏感的用户提高可视性。深色可以降低屏幕的整体视觉亮度,减少对眼睛的视觉压力。
- 让所有人都可以在光线较暗的环境中更轻松地使用设备。
适配方法有两种:
1.手动适配(资源替换)
官方文档中提到的继承Theme.AppCompat.DayNight或者Theme.MaterialComponents.DayNight的方法,但这只是将我们使用的各种View的默认样式进行了适配,并不太适用于实际项目的适配。因为具体的项目中的View都按照设计的风格进行了重定义。
其实适配的方法很简单,类似屏幕适配、国际化的操作,并不需要继承上面的主题。比如你要修改颜色,就在res下新建values-night目录,创建对应的colors.xml文件。将具体要修改的色值定义在里面。图标之类的也是一个思路,创建对应的drawable-night目录。
只要你之前的代码不是硬编码且代码规范,那么适配起来还是很轻松。
2.自动适配(ForceDark)
Android10提供ForceDark功能。一如其名,此功能可让开发者快速实现深色主题背景,而无需明确设置DayNight主题背景。
如果您的应用采用浅色主题背景,则ForceDark会分析应用的每个视图,并在相应视图在屏幕上显示之前,自动应用深色主题背景。有些开发者会混合使用ForceDark和本机实现,以缩短实现深色主题背景所需的时间。
应用必须选择启用ForceDark,方法是在其主题背景中设置android:forceDarkAllowed="true"。此属性会在所有系统及AndroidX提供的浅色主题背景(例如Theme.Material.Light)上设置。使用ForceDark时,您应确保全面测试应用,并根据需要排除视图。
如果您的应用使用DarkTheme主题(例如Theme.Material),则系统不会应用ForceDark。同样,如果应用的主题背景继承自DayNight主题(例如Theme.AppCompat.DayNight),则系统不会应用ForceDark,因为会自动切换主题背景。
您可以通过android:forceDarkAllowed布局属性或setForceDarkAllowed(boolean)在特定视图上控制ForceDark。
上述内容我直接照搬文档的说明。总结一下,使用ForceDark需要注意几点:
- 如果使用的是DayNight或DarkTheme主题,则设置forceDarkAllowed不生效。
- 如果有需要排除适配的部分,可以在对应的View上设置forceDarkAllowed为false。
这里说说我实际使用此方法的感受:整体还是不错的,设置的色值会自动取反。但也因此颜色不受控制,能否达到预期效果是个需要注意的问题。追求快速适配可以采取此方案。
手动切换主题
使用AppCompatDelegate.setDefaultNightMode(@NightModeintmode)方法,其中参数mode有以下几种:
- 浅色-MODE_NIGHT_NO
- 深色-MODE_NIGHT_YES
- 由省电模式设置-MODE_NIGHT_AUTO_BATTERY
- 系统默认-MODE_NIGHT_FOLLOW_SYSTEM
下面的代码是官方Demo中的使用示例:
publicclassThemeHelper{ publicstaticfinalStringLIGHT_MODE="light"; publicstaticfinalStringDARK_MODE="dark"; publicstaticfinalStringDEFAULT_MODE="default"; publicstaticvoidapplyTheme(@NonNullStringthemePref){ switch(themePref){ caseLIGHT_MODE:{ AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO); break; } caseDARK_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":
这样在onConfigurationChanged方法中就可以获取:
@Override publicvoidonConfigurationChanged(@NonNullConfigurationnewConfig){ super.onConfigurationChanged(newConfig); intcurrentNightMode=newConfig.uiMode&Configuration.UI_MODE_NIGHT_MASK; switch(currentNightMode){ caseConfiguration.UI_MODE_NIGHT_NO: //关闭 break; caseConfiguration.UI_MODE_NIGHT_YES: //开启 break; default: break; } }
详细的内容你可以参看官方文档和官方Demo。
判断深色主题是否开启
其实和上面onConfigurationChanged方法同理:
publicstaticbooleanisNightMode(Contextcontext){ intcurrentNightMode=context.getResources().getConfiguration().uiMode& Configuration.UI_MODE_NIGHT_MASK; returncurrentNightMode==Configuration.UI_MODE_NIGHT_YES; }
5.标识符和数据
对不可重置的设备标识符实施了限制
受影响的方法包括:
- Build
- getSerial()
- TelephonyManager
- getImei()
- getDeviceId()
- getMeid()
- getSimSerialNumber()
- getSubscriberId()
从Android10开始,应用必须具有READ_PRIVILEGED_PHONE_STATE特许权限才能正常使用以上这些方法。
如果你的应用没有该权限,却仍然使用了以上的方法,则返回的结果会因目标SDK版本而异:
- 如果应用以Android10或更高版本为目标平台,则会发生SecurityException。
- 如果应用以Android9(API级别28)或更低版本为目标平台,则相应方法会返回null或占位符数据(如果应用具有READ_PHONE_STATE权限)。否则,会发生SecurityException。
这项改动表示第三方应用无法获取DeviceID这类唯一标识。如果你需要唯一标识符,请参阅文档:唯一标识符的最佳做法。
当然你也可以试试移动安全联盟(MSA)联合多家厂商共同开发的统一补充设备标识调用SDK。据说还有点不稳定,因为我暂时还没有尝试过,所以不做评价。
限制了对剪贴板数据的访问权限
除非您的应用是默认输入法(IME)或是目前处于焦点的应用,否则它无法访问Android10或更高版本平台上的剪贴板数据。
对启用和停用WLAN实施了限制
以Android10或更高版本为目标平台的应用无法启用或停用WLAN。
WifiManager.setWifiEnabled()方法始终返回false。
如果您需要提示用户启用或停用WLAN,请使用设置面板。
6.其他
Android10上对折叠屏设备有了更好的支持,对于有折叠屏适配的需求,可以参看为可折叠设备构建应用和华为折叠屏应用开发指导。
以上内容只是Android10中比较大的几项变化,完整的内容可以查看官方文档。
参考
OPPO-AndroidQ版本应用兼容性适配指导
面向开发者的Android10
用阿里巴巴APP的案例,教你如何快速适配「深色模式」
到此这篇关于Android10适配攻略小结的文章就介绍到这了,更多相关Android10适配内容请搜索毛票票以前的文章或继续浏览下面的相关文章希望大家以后多多支持毛票票!
声明:本文内容来源于网络,版权归原作者所有,内容由互联网用户自发贡献自行上传,本网站不拥有所有权,未作人工编辑处理,也不承担相关法律责任。如果您发现有涉嫌版权的内容,欢迎发送邮件至:czq8825#qq.com(发邮件时,请将#更换为@)进行举报,并提供相关证据,一经查实,本站将立刻删除涉嫌侵权内容。