Android-适配-03-Android7-0(24)适配

280 阅读12分钟

1. 安装时解析错误

我们的App通常会有检查更新的功能。用户在收到提示更新并且下载完后,会自动打开安装页面让用户来去安装。这时就会出现安装错误的问题,这类的问题的可能性比较多。比如较低版本的App想要覆盖已有的较高版本App会提示安装未完成,或是签名不一致导致的。不过7.0上常见的有以下两种情况。

1.1. 应用间共享文件

targetSdkVersion大于等于的24的App中,但是我们没有去适配7.0。那么在调用安装页面,或修改用户头像操作时,就会失败。那么就需要你去适配7.0或是将targetSdkVersion改为24以下(不推荐)。适配的方法这里就不细讲,大家可以看鸿洋大神的 Android 7.0 行为变更 通过FileProvider在应用间共享文件吧 这篇文章。

1.2. APK signature scheme v2

Android 7.0 引入一项新的应用签名方案 APK Signature Scheme v2,它能提供更快的应用安装时间和更多针对未授权 APK 文件更改的保护。在默认情况下,Android Studio 2.2 和 Android Plugin for Gradle 2.2 会使用 APK Signature Scheme v2 和传统签名方案来签署您的应用。详细看安卓官方说明

简单地说就是任何方式的篡改APK 文件,在利用了V2签名的apk上会失效。

我所用的Android Studio目前是2.3.3 在 Gradle 2.2.3时。打包页面是这样

image.png

可以看到默认是V1 和V2选中的。

1)只勾选v1签名就是传统方案签署,但是在7.0上不会使用V2安全的验证方式。 2)只勾选V2签名7.0以下会显示未安装,7.0上则会使用了V2安全的验证方式。 3)同时勾选V1和V2则所有版本都没问题。

这里问题就来了,默认全部勾选,按道理所有版本是没有问题的。那么我们为什么还是安装错误?其实是因为我们项目采用了美团的快速生成渠道包方案。这种方案不适用于V2的签名方案。(因为实现思路就是给已有的apk文件中添加空的渠道文件)

解决办法:

1.如果你的渠道较少,可以用gradle方式的多渠道打包。渠道多的话就不适用了。

2.毕竟V2不是强制的,那么我们要用传统方案签署,可以打开模块级build.gradle 文件,然后将行v2SigningEnabled false 添加到您的版本签名配置中:

android {
    ...
    defaultConfig { ... }
    signingConfigs {
      release {
        storeFile file("myreleasekey.keystore")
        storePassword "password"
        keyAlias "MyReleaseKey"
        keyPassword "password"
        v2SigningEnabled false  //<--这里
      }
    }
  }

或者将Gradle 升级为2.3以上。那么打包页面是这样 image.png

我们可以不勾选V2选项。 前两种方法是比较快速的可以解决问题,但是一旦这种安全措施被强制(毕竟我们可以感受到安卓在安全方面的努力,比如权限控制、应用间共享文件),我们怎么办。其实美团早早发现了这个问题,具体看这篇 新一代开源Android渠道包生成工具Walle。里面有深度的原理讲解,满满的干货。

##2 权限更改

随着Android版本越来越高,Android对隐私的保护力度也越来越大。从Android6.0引入的动态权限控制(Runtime Permissions)到Android7.0的“私有目录被限制访问”,“StrictMode API 政策”。这些更改在为用户带来更加安全的操作系统的同时也为开发者带来了一些新的任务。如何让你的APP能够适应这些改变而不是cash,是摆在每一位Android开发者身上的责任。

###2.1 目录被限制访问

一直以来,在目录及文件的访问保护方面iOS做的是很到位的,如:iOS的沙箱机制。但,Android在这方面的保护就有些偏弱了,在Android中应用可以读写手机存储中任何一个目录及文件,这也带来了很多的安全问题。现在Android也在着力解决这一问题。

在Android7.0中为了提高私有文件的安全性,面向 Android N 或更高版本的应用私有目录将被限制访问。对于这个权限的更改开发者需要留意一下改变:

应对策略:这项权限的变更将意味着你无法通过File API访问手机存储上的数据了,基于File API的一些文件浏览器等也将受到很大的影响,看到这大家是不是惊呆了呢,不过迄今为止,这种限制尚不能完全执行。 应用仍可能使用原生 API 或 File API 来修改它们的私有目录权限。 但是,Android官方强烈反对放宽私有目录的权限。可以看出收起对私有文件的访问权限是Android将来发展的趋势。

  • 给其他应用传递 file:// URI 类型的Uri,可能会导致接受者无法访问该路径。 因此,在Android7.0中尝试传递 file:// URI 会触发 FileUriExposedException。

应对策略:大家可以通过使用FileProvider来解决这一问题。

应对策略:大家可以通过[ContentResolver.openFileDescriptor()]([developer.android.com/reference/a…link.jianshu.com?t=https://developer.…, java.lang.String))来访问由 DownloadManager 公开的文件。

###2.2 应用间共享文件

在Android7.0系统上,Android 框架强制执行了 StrictMode API 政策禁止向你的应用外公开 file:// URI。 如果一项包含文件 file:// URI类型 的 Intent 离开你的应用,应用失败,并出现 FileUriExposedException 异常,如调用系统相机拍照,或裁切照片

应对策略:若要在应用间共享文件,可以发送 content:// URI类型的Uri,并授予 URI 临时访问权限。 进行此授权的最简单方式是使用 FileProvider类。 如需有关权限和共享文件的更多信息,请参阅共享文件。

####2.2.1 调用系统相机拍照

在Android7.0之前,如果你想调用系统相机拍照可以通过以下代码来进行:

File file=new File(Environment.getExternalStorageDirectory(), "/temp/"+System.currentTimeMillis() + ".jpg");
if (!file.getParentFile().exists())file.getParentFile().mkdirs();
Uri imageUri = Uri.fromFile(file);
Intent intent = new Intent();
intent.setAction(MediaStore.ACTION_IMAGE_CAPTURE);//设置Action为拍照
intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);//将拍取的照片保存到指定URI
startActivityForResult(intent,1006);

在Android7.0上使用上述方式调用系统相拍照会抛出如下异常:

android.os.FileUriExposedException: file:////storage/emulated/0/temp/1474956193735.jpg exposed beyond app through Intent.getData()
at android.os.StrictMode.onFileUriExposed(StrictMode.java:1799)
at android.net.Uri.checkFileUriExposed(Uri.java:2346)
at android.content.Intent.prepareToLeaveProcess(Intent.java:8933)
at android.content.Intent.prepareToLeaveProcess(Intent.java:8894)
at android.app.Instrumentation.execStartActivity(Instrumentation.java:1517)
at android.app.Activity.startActivityForResult(Activity.java:4223)
...
at android.app.Activity.startActivityForResult(Activity.java:4182)

这是由于Android7.0执行了“StrictMode API 政策禁”的原因,不过小伙伴们不用担心,上文讲到了可以用FileProvider来解决这一问题, 现在我们就来一步一步的解决这个问题。

####2.2.2 使用FileProvider

使用FileProvider的大致步骤如下: 第一步:在manifest清单文件中注册provider

<provider
    android:name="android.support.v4.content.FileProvider"
    android:authorities="com.jph.takephoto.fileprovider"
    android:grantUriPermissions="true"
    android:exported="false">
    <meta-data
        android:name="android.support.FILE_PROVIDER_PATHS"
        android:resource="@xml/file_paths" />
</provider>

心得:exported:要求必须为false,为true则会报安全异常。grantUriPermissions:true,表示授予 URI 临时访问权限。

第二步:指定共享的目录

为了指定共享的目录我们需要在资源(res)目录下创建一个xml目录,然后创建一个名为“file_paths”(名字可以随便起,只要和在manifest注册的provider所引用的resource保持一致即可)的资源文件,内容如下:

<?xml version="1.0" encoding="utf-8"?>
<resources>
    <paths>
        <external-path path="" name="camera_photos" />
    </paths>
</resources>

这样配置就代表应用可以使用Environment.getExternalStorageDirectory()/phototest 目录以及其子目录的文件

  • 在paths节点下支持以下几个子节点:
<root-path/> :代表设备的根目录new File("/")
<files-path/> : 代表context.getFilesDir()
<cache-path/> : 代表context.getCacheDir()
<external-path/> : 代表Environment.getExternalStorageDirectory()
<external-files-path/> : 代表context.getExternalFilesDirs()
<external-cache-path/> : 代表getExternalCacheDirs()

心得:上述代码中path="",是有特殊意义的,它代码根目录,也就是说你可以向其它的应用共享根目录及其子目录下任何一个文件了,如果你将path设为path="pictures", 那么它代表着根目录下的pictures目录(eg:/storage/emulated/0/pictures),如果你向其它应用分享pictures目录范围之外的文件是不行的。

第三步:使用FileProvider

上述准备工作做完之后,现在我们就可以使用FileProvider了。 还是以调用系统相机拍照为例,我们需要将上述拍照代码修改为如下:

File file=new File(Environment.getExternalStorageDirectory(), "/temp/"+System.currentTimeMillis() + ".jpg");
if (!file.getParentFile().exists())file.getParentFile().mkdirs();
Uri imageUri = FileProvider.getUriForFile(context, "com.jph.takephoto.fileprovider", file);//通过FileProvider创建一个content类型的Uri
Intent intent = new Intent();
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); //添加这一句表示对目标应用临时授权该Uri所代表的文件
intent.setAction(MediaStore.ACTION_IMAGE_CAPTURE);//设置Action为拍照
intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);//将拍取的照片保存到指定URI
startActivityForResult(intent,1006);

上述代码中主要有两处改变:

  1. 将之前Uri的scheme类型为file的Uri改成了有FileProvider创建一个content类型的Uri。
  2. 添加了intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);来对目标应用临时授权该Uri所代表的文件。

心得:上述代码通过FileProviderUri getUriForFile (Context context, String authority, File file) 静态方法来获取Uri,该方法中authority参数就是清单文件中注册provider的android:authorities="com.jph.takephoto.fileprovider"。 对Web服务器如tomcat,IIS比较熟悉的小伙伴,都只知道为了网站内容的安全和高效,Web服务器都支持为网站内容设置一个虚拟目录,其实FileProvider也有异曲同工之处。

getUriForFile方法获取的Uri打印出来如下:

content://com.jph.takephoto.fileprovider/camera_photos/temp/1474960080319.jpg`。  

其中camera_photos就是file_paths.xml中paths的name。

因为上述指定的path为path="",所以content://com.jph.takephoto.fileprovider/camera_photos/代表的真实路径就是根目录,即:/storage/emulated/0/content://com.jph.takephoto.fileprovider/camera_photos/temp/1474960080319.jpg代表的真实路径是:/storage/emulated/0/temp/1474960080319.jpg

另外,推荐大家使用开源工具库TakePhotoTakePhoto是一款在Android设备上获取照片(拍照或从相册、文件中选择)、裁剪图片、压缩图片的开源工具库。

####2.2.3 裁切照片

在Android7.0之前,你可以通过如下方法来裁切照片:

File file=new File(Environment.getExternalStorageDirectory(), "/temp/"+System.currentTimeMillis() + ".jpg");
if (!file.getParentFile().exists())file.getParentFile().mkdirs();
Uri outputUri = Uri.fromFile(file);
Uri imageUri=Uri.fromFile(new File("/storage/emulated/0/temp/1474960080319.jpg"));
Intent intent = new Intent("com.android.camera.action.CROP");
intent.setDataAndType(imageUri, "image/*");
intent.putExtra("crop", "true");
intent.putExtra("aspectX", 1);
intent.putExtra("aspectY", 1);
intent.putExtra("scale", true);
intent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri);
intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString());
intent.putExtra("noFaceDetection", true); // no face detection
startActivityForResult(intent,1008);

和拍照一样,上述代码在Android7.0上同样会引起android.os.FileUriExposedException异常,解决办法就是上文说说的使用FileProvider。

然后,将上述代码改为如下即可:

File file=new File(Environment.getExternalStorageDirectory(), "/temp/"+System.currentTimeMillis() + ".jpg");
if (!file.getParentFile().exists())file.getParentFile().mkdirs();
Uri outputUri = FileProvider.getUriForFile(context, "com.jph.takephoto.fileprovider",file);
Uri imageUri=FileProvider.getUriForFile(context, "com.jph.takephoto.fileprovider", new File("/storage/emulated/0/temp/1474960080319.jpg");//通过FileProvider创建一个content类型的Uri
Intent intent = new Intent("com.android.camera.action.CROP");
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
intent.setDataAndType(imageUri, "image/*");
intent.putExtra("crop", "true");
intent.putExtra("aspectX", 1);
intent.putExtra("aspectY", 1);
intent.putExtra("scale", true);
intent.putExtra(MediaStore.EXTRA_OUTPUT, outputUri);
intent.putExtra("outputFormat", Bitmap.CompressFormat.JPEG.toString());
intent.putExtra("noFaceDetection", true); // no face detection
startActivityForResult(intent,1008);

3. PopupWindow位置不正确

7.0系统的手机上,PopupWindow弹出位置不正确。有两种可能:

3.1.我们使用了update方法,同时设置了GravityGravity.NO_GRAVITY没事)。

因为在update方法中有调用computeGravity方法去获取Gravity。(7.0以下没有获取Gravity进行更新判断)

	public void update() {

	    // 省略部分代码

        final int newGravity = computeGravity();
        if (newGravity != p.gravity) {
            p.gravity = newGravity;
            update = true;
        }

        if (update) {
            setLayoutDirectionFromAnchor();
            mWindowManager.updateViewLayout(mDecorView, p);
        }
    }

Android 7.0computeGravity方法源码

private int computeGravity() {
    int gravity = Gravity.START | Gravity.TOP;
    if (mClipToScreen || mClippingEnabled) {
        gravity |= Gravity.DISPLAY_CLIP_VERTICAL;
    }
    return gravity;
}

Android 7.1computeGravity方法

private int computeGravity() {
    int gravity = mGravity == Gravity.NO_GRAVITY ?  Gravity.START | Gravity.TOP : mGravity;
    if (mIsDropdown && (mClipToScreen || mClippingEnabled)) {
        gravity |= Gravity.DISPLAY_CLIP_VERTICAL;
    }
    return gravity;
}

很显然在7.0上我们设置的Gravity被覆盖了。解决就很简单了,不使用update方法。如果你真的要使用可以参考这篇文章的方法。

3.2.PopupWindow高度为MATCH_PARENT在显示的时候调用showAsLocation方法时,PopupWindow并没有在指定控件的下方显示。如果使用showAsDropDown,会全屏显示。

解决方法:

1.最简单的解决方法就是指定 PopupWindow 的高度为 WRAP_CONTENT, 调用 showAsDropDown方法。

2.或者弹出时做一下判断处理(代码来自PopupWindowCompat

if (Build.VERSION.SDK_INT >= 24) { // Android 7.x中,PopupWindow高度为match_parent时,会出现兼容性问题,需要处理兼容性
    int[] location = new int[2]; // 记录anchor在屏幕中的位置
    anchor.getLocationOnScreen(location);
    int offsetY = location[1] + anchor.getHeight();
    if (Build.VERSION.SDK_INT >= 25) { // Android 7.1 ,8.0中,PopupWindow高度为 match_parent 时,会占据整个屏幕
        // 故而需要在 Android 7.1上再做特殊处理
        int screenHeight = ScreenUtils.getScreenHeight(context); // 获取屏幕高度
        popupWindow.setHeight(screenHeight - offsetY); // 重新设置 PopupWindow 的高度
    }
    popupWindow.showAtLocation(anchor, Gravity.NO_GRAVITY, 0, offsetY);
} else {
    popupWindow.showAsDropDown(anchor);
}

4. 后台优化

小伙伴们都知道在Android中有一些隐式广播,使用这些隐式广播可以做一些特定的功能,如,当手机网络变成WiFi时自动下载更新包等。 但,这些隐式广播会在后台频繁启动已注册侦听这些广播的应用,从而带来很大的电量消耗,为缓解这一问题来提升设备性能和用户体验,在Android 7.0中删除了三项隐式广播,以帮助优化内存使用和电量消耗。

Android 7.0 应用了以下优化措施:

  • 在 Android 7.0上 应用不会收到 CONNECTIVITY_ACTION 广播,即使你在manifest清单文件中设置了请求接受这些事件的通知。 但,在前台运行的应用如果使用BroadcastReceiver 请求接收通知,则仍可以在主线程中侦听 CONNECTIVITY_CHANGE。
  • 在 Android 7.0上应用无法发送或接收 ACTION_NEW_PICTURE 或ACTION_NEW_VIDEO 类型的广播。

应对策略:Android 框架提供多个解决方案来缓解对这些隐式广播的需求。 例如,JobScheduler API 提供了一个稳健可靠的机制来安排满足指定条件(例如连入无线流量网络)时所执行的网络操作。 您甚至可以使用 JobScheduler API 来适应内容提供程序变化。

另外,大家如果想了解更多关于后台的优化可查阅后台优化

移动设备会经历频繁的连接变更,例如在 Wi-Fi 和移动数据之间切换时。 目前,可以通过在应用清单中注册一个接收器来侦听隐式 CONNECTIVITY_ACTION 广播, 让应用能够监控这些变更。 由于很多应用会注册接收此广播,因此单次网络切换即会导致所有应用被唤醒并同时处理此广播。