近期因为应用市场要求,需要将targetsdkversion升级到26
之前博客中我们了解过targetsdkversion的重要性,当时我们建议轻易不要改动这个参数。
但是这次因为应用市场的硬性要求,我们必须做升级,那么就需要面对升级后带来的兼容性问题。
1、动态权限管理
最明显的问题就是权限管理,在6.0加入的动态权限需要我们手动进行处理。这个就老生常谈了,这里不展开说了。
2、ContentResolver
处理完权限我们运行程序后,发现app竟然crash了,报错:
java.lang.SecurityException: Failed to find provider xxx for user 0; expected to find a valid ContentProvider for this authority
调查发现我们使用了
getContentResolver().registerContentObserver(uri, false, observer)或
getContentResolver().notifyChange(uri, observer)
查询相关文档得知8.0对ContentResolver加了一层安全机制,防止外部访问app内部使用的数据。
那么怎么解决这个问题?
首先我们需要自定义一个ContentProvider,如果仅仅是为了通知,可以不实现抽象函数
然后在AndroidManifest中
最后在使用时uri需要是content://
getContentResolver().registerContentObserver(new URI().parse("content://xxxx/tablename"), false, observer)
getContentResolver().notifyChange(new URI().parse("content://xxxx/tablename"), null)
3、FileProvider(File URI)
在8.0(实际是7.0)下,当app对外传递file URI的时候会导致一个FileUriExposedException。
比如说在app拉起安装apk,之前代码是:
Intent i = new Intent(Intent.ACTION_VIEW);
i.setDataAndType(Uri.parse(file), "application/vnd.android.package-archive");
i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(i);
当tagsdkversion升级到26就会出现问题。这是因为7.0添加了一项安全机制,app不再允许对外暴露File URI(file://URI),而用Content URI(content://URI)来代替,Content URI会授予URI临时访问权限,提高文件访问的安全性。
那么如何使用Content URI?
添加FileProvider即可,它是ContentProvider的一个子类。
首先,在res的xml目录(没有则新建)下,新增一个文件file_provider_paths.xml:
这里注意paths一定要小写,大写也不会报错,但是会造成一些麻烦;再有path不能为空。
这里就要详细解释一下:
/storage/emulated/0/path/
。所以在使用要格外注意,一定要与真实地址对应上。比如真实地址为/data/data/
那么就是:
而最终得到的Content URI则是content://
可以看到使用authorities(在后面)和name隐藏了真实路径,这样就防止对外暴露了路径
其次,在AndroidManifest中添加:
exported和grantUriPermissions都不能缺,否则会引起错误
这里的authorities就是最后Content URI中的,而且后面还会使用到
然后,我需要重写拉起安装的代码,如下:
Intent i = new Intent(Intent.ACTION_VIEW);
Uri contentUri;
if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.N){
i.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
contentUri = FileProvider.getUriForFile(context, authorities, file);
}
else{
contentUri = Uri.parse(file);
}
i.setDataAndType(contentUri, "application/vnd.android.package-archive");
i.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
startActivity(i);
注意这里的authorities一定要与AndroidManifest中的保持一致。
最后再记录一下开发过程中遇到的几个问题:
(1)InstallStart: Requesting uid 10087 needs to declare permission android.permission.REQUEST_INSTALL_PACKAGES
在8.0以上,需要在AndroidManifest中添加 权限
(2)安装包解析失败
这里有两种情况,可以根据日志分析出来:
(1)路径错误:日志中有No such file or directory这样的字眼。检查文件路径和上面的配置是否有误,比如文件在sd卡而xml中是应用cache中。
(2)权限问题,日志如下:
W/System.err: java.lang.SecurityException: Permission Denial: opening provider ... from ProcessRecord ... that is not exported from ...
W/PackageInstaller: InstallStaging:Error staging apk from content URIPermission Denial: opening provider ... from ProcessRecord ... that is not exported from ...
网上对有不少说法:不能使用sd卡,必须用应用空间;还有将android:exported设为true。事实证明都不对,尤其android:exported设为true会造成java.lang.SecurityException: Provider must not be exported错误。
这个问题实际上是没有给intent添加Intent.FLAG_GRANT_READ_URI_PERMISSION这个flag。尤其要注意,因为这里要添加两个flag,一定要使用addFlags。如果使用setFlags,由于Intent.FLAG_ACTIVITY_NEW_TASK在后面设置,会丢失前面的flag,会导致上面的问题。
(3)FileProvider冲突
当lib或module中的AndroidManifest添加了FileProvider,而主项目也需要添加时,就会出现冲突,gradle编译错误如下:
Error:C:***AndroidManifest.xml:352:13-62 Error:
Attribute provider#android.support.v4.content.FileProvider@authorities value=(***.fileProvider) from AndroidManifest.xml:352:13-62
is also present at [xxxx:xxx] AndroidManifest.xml:19:13-64 value=(***.fileprovider).
Suggestion: add 'tools:replace="android:authorities"' to
错误上建议我们添加tools:replace="android:authorities"来解决问题,实际上我发现这并不能很好的解决问题。那么怎么办?
简单的方法是我们自定义一个类,继承FileProvider,在AndroidManifest中使用这个自定义类,这样就可以避免冲突了。如:
4、DownloadManager(ContentResolver.openFileDescriptor)
同样在7.0上,为了安全起见对DownloadManager也做了修改,抛弃了COLUMN_LOCAL_FILENAME字段。
在之前我们使用DownloadManager,会使用一个receiver来接收下载结束并处理后续,代码如下:
@Override
public void onReceive(Context context, Intent intent) {
DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE);
long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1);
if (downloadId == mId) {
DownloadManager.Query query = new DownloadManager.Query();
query.setFilterById(mId);
Cursor cursor = downloadManager.query(query);
if (cursor.moveToFirst()) {
int urlId = cursor.getColumnIndex(DownloadManager.COLUMN_URI);
int stateId = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS);
String url = cursor.getString(urlId);
int state = cursor.getInt(stateId);
int pathId = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_FILENAME);
tmp = cursor.getString(pathId);
...
可以看到从cursor里可以得到下载状态,url及下载地址等信息。
但是当targetsdkversion升级到7.0或以上后,在7.0及以上机器上就会crash,报错如下:
java.lang.SecurityException: COLUMN_LOCAL_FILENAME is deprecated; use ContentResolver.openFileDescriptor() instead
如上所说,为了安全起见抛弃了COLUMN_LOCAL_FILENAME(还有COLUMN_LOCAL_URI字段),让我们用ContentResolver.openFileDescriptor()代替。
那么ContentResolver.openFileDescriptor()又是什么?
简单来说会得到一个ParcelFileDescriptor对象,并可以进一步得到一个FileDescriptor对象。
我们可以通过它们打开文件流,比如:
int pathId = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI);
String localUri = cursor.getString(pathId);
try {
ParcelFileDescriptor parcelFileDescriptor = context.getContentResolver().openFileDescriptor(Uri.parse(localUri), "r");
FileDescriptor fileDescriptor = parcelFileDescriptor.getFileDescriptor();
FileInputStream fileInputStream = new FileInputStream(fileDescriptor);
...
} catch (FileNotFoundException e) {
e.printStackTrace();
}
(使用FileDescriptor还有其它方法,都是通过流来处理)
那么我们如果只需要下载文件的完整路径即可,这时就不需要openFileDescriptor,只需要将上面获取下载文件的两行代码替换为:
String tmp = null;
if(Build.VERSION.SDK_INT > Build.VERSION_CODES.M){
int pathId = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI);
String localUri = cursor.getString(pathId);
tmp = Uri.parse(localUri).getPath();
}
else{
int pathId = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_FILENAME);
tmp = cursor.getString(pathId);
}
在7.0之上处理一下就可以了。
5、后台service
这里有两点需要注意:
(1)当app在后台时,startService启动一个background service将不再允许,会导致crash。bindService不受影响。
解决方法是避免app不在前端时启动服务;或者如果一定需要app在后台启动服务,请启动一个foreground service,但是需要一个常驻的notifacation; 或者使用bindService来启动服务。具体做法有很多文章,这里就不详细写了。
(2)service的存活差异
经测试发现,targetsdkversion的改变对service(background service)的存活也是有影响的。
这块我有一篇详细的文章来讲解,请见《探讨8.0版本下后台service存活机制及保活》
对于targetsdkversion 26的app在8.0及以上版本,想要长时间存活,最好的方式就是使用foreground service;或者将service绑定到application上bindService;另外一个解决方案就是请求加入耗电白名单,但是这个对用户不友好。
6、集合API变更
在android8.0上AbstractCollection.removeAll(null)和AbstractCollection.retainAll(null)会引发NullPointerException;之前版本则不会。所以我们在使用这两个函数前要确保参数不是null,必要是需要判空。
可以看看这两个函数的代码:
public boolean removeAll(Collection> c) {
Objects.requireNonNull(c);
boolean modified = false;
Iterator> it = iterator();
while (it.hasNext()) {
if (c.contains(it.next())) {
it.remove();
modified = true;
}
}
return modified;
}
public boolean retainAll(Collection> c) {
Objects.requireNonNull(c);
boolean modified = false;
Iterator it = iterator();
while (it.hasNext()) {
if (!c.contains(it.next())) {
it.remove();
modified = true;
}
}
return modified;
}
可以看到都在首行调用了Objects.requireNonNull(c),这个代码是:
public static T requireNonNull(T obj) {
if (obj == null)
throw new NullPointerException();
return obj;
}
可以看到如果是null就会抛出一个空指针错误。
7、通知Notification
在Android8.0上,通知做了较大的改动,增加了分组和渠道机制,这样更加方便了用户对通知的管理。
那么我们app中的notification相关代码就需要变动,否则可以无法发出通知。
我们需要为notification增加分组和渠道,如下:
String groupId =
"group1"
;
NotificationChannelGroup group =
new
NotificationChannelGroup(groupId,
""
);
notificationManager.createNotificationChannelGroup(group);
String channelId =
"channel1"
;
NotificationChannel channel =
new
NotificationChannel(channelId,
"推广信息"
, NotificationManager.IMPORTANCE_DEFAULT);
channel.setDescription(
"推广信息"
);
channel.setGroup(groupId);
notificationManager.createNotificationChannel(channel);
NotificationCompat.Builder builder =
new NotificationCompat.Builder(context, channelId);
//上面使用support包中的NotificationCompat,但是版本需要是26及以上
//或者不使用support包中的类,直接使用Notification.Builder,但是要进行版本判断
//Notification.Builder builder;
//if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
// builder = new Notification.Builder(BaseApp.getAppContext(), App.CHANNEL_ID);
//}
//else{
// builder = new Notification.Builder(BaseApp.getAppContext());
//}
...
notificationManager(notificationId, builder.build())
这样通知就能正常发出并展示了,但是其实group并不是必须的,所以可以只设置channel,如:
String channelId =
"channel1"
;
NotificationChannel adChannel =
new
NotificationChannel(channelId,
"推广信息"
, NotificationManager.IMPORTANCE_DEFAULT);
adChannel.setDescription(
"推广信息"
);
notificationManager.createNotificationChannel(adChannel);
NotificationCompat.Builder builder =
new NotificationCompat.Builder(context, channelId);
...
notificationManager(notificationId, builder.build())
8、隐式广播
android8.0之后静态注册(manifest中)的隐式广播将不再起作用,但是有一些隐式广播除外,静态注册它们仍然可以接收到广播。
解决方法是将隐式广播改成动态注册。
静态注册的显式广播不受影响
9、悬浮窗
使用SYSTEM_ALERT_WINDOW 权限的应用无法再使用以下窗口类型来在其他应用和系统窗口上方显示提醒窗口:
•TYPE_PHONE
•TYPE_PRIORITY_PHONE
•TYPE_SYSTEM_ALERT
•TYPE_SYSTEM_OVERLAY
•TYPE_SYSTEM_ERROR
相反,应用必须使用名为 TYPE_APPLICATION_OVERLAY 的新窗口类型。
所以我们要在代码中判断版本:
if (Build.VERSION.SDK_INT>=26) {
windowParams.type= WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY;
}else{
windowParams.type= WindowManager.LayoutParams.TYPE_SYSTEM_ALERT;
}
而且在manifest添加权限后,如果在6.0及以上系统中还需要动态请求相关权限:
if (Build.VERSION.SDK_INT >= 23) {
if(!Settings.canDrawOverlays(getApplicationContext())) {
Intent intent = new Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,Uri.parse("package:" + getPackageName()));
startActivityForResult(intent,1);
return;
} else {
}
} else {
}
@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
if (requestCode == 1) {
if (Build.VERSION.SDK_INT >= 23) {
if (!Settings.canDrawOverlays(this)) {
...
}
}
}
}