云课堂之前的TargetSdk是19,而现在Android系统已经出到了26,市面上Android各个版本的占比如下图:
Version | Codename | API | Distribution |
---|---|---|---|
2.3.3-2.3.7 | Gingerbread | 10 | 0.5% |
4.0.3-4.0.4 | IceCream Sandwich | 15 | 0.5% |
4.1.x | Jelly Bean | 16 | 2.2% |
4.2.x | Jelly Bean | 17 | 3.1% |
4.3 | Jelly Bean | 18 | 0.9% |
4.4 | KitKat | 19 | 13.8% |
5.0 | Lollipop | 21 | 6.4% |
5.1 | Lollipop | 22 | 20.8% |
6.0 | Marshmallow | 23 | 30.9% |
7.0 | Nougat | 24 | 17.6% |
7.1 | Nougat | 25 | 3.0% |
8.0 | Oreo | 26 | 0.3% |
根据2017.11月的统计数据显示,目前在使用的安卓手机中,Android5.0以上的份额占了79%,对于这部分用户,他们在使用云课堂的时候,没法体验新版本系统的特性,例如运行时权限,以及新版系统对应用在视觉及性能做出的优化等等。而对于开发来说,我们也可以使用最前沿的技术,在谷歌每次升级系统的时候,我们也能够更快的适配上去,让用户第一时间体验新版本下的云课堂。所以,升级应用的TargetSdk是有必要的。
分析之前,我们先简单说明一下几个概念,什么是compileSdkVersion,minSdkVersion以及targetSdkVersion。
对于compileSdkVersion,我们应该总是使用最新的SDK进行编译,避免使用新弃用的API,并且为使用新的API做好准备。
对于minSdkVersion,除非某些库的minSdkVersion变了,或者核心代码中必须使用更新的API,我们才改变它的值。
对于targetSdkVersion,我们需要注意代码是否适应更新后的版本号,要进行全面的测试,要针对谷歌发布的每个新版本里的行为变化进行处理。
对于云课堂App来说,之前的TargetSdkVersion是19,现在要升到25,这中间跨越了多个版本,因此首先要知晓每个版本的行为更变。这里举个例子,对于运行时动态权限,之前我们都是在清单文件中写明需要的权限,应用安装好,自动拥有。而6.0系统以上,需要代码去申请,最重要的是,用户还可以拒绝。比如读写磁盘权限,一旦拒绝了,对于之前的不够健壮的代码,功能失效且不说,时常会冒出一个空指针奔溃,或者UnsecurityException等等。
另外教育产品部,有多个多产品,共用一些通用的模块,因此这次改动会涉及到所有的产品线,并且最终要确保都能无感知的升级TargetSDkVersion。
风险是巨大的,任务是艰巨的,但总得往前走。
file://URI
。如果一项包含文件的file://uri类型的Intent离开你的应用,应用将抛出FileUriExposedException异常。会影响调用系统相机拍照,裁切照片,应用内下载apk安装应用。在知晓每个版本升级的行为变更后,考虑到每个模块都需要检查代码,比如它是否访问了File,是否有打开相机、相册、麦克风,是否有读写外置SDcard磁盘操作等等。 我们考虑使用Android 自定义lint检查。
动态权限申请。需要一套动态权限的工具,它应该能够非常灵活,在调用需要权限的方法前,增加权限判断,有权限则执行,无权限则不执行,然后弹出权限申请框,在获取权限之后又能继续执行。在多方调研后,我们使用了PermissionsDispatcher库,在需要权限的方法上添加注解。
有很多地方需要使用provider,因此在framework层提供一个FileProvider,统一管理需要向外暴露file://URI类型的Intent。
下面一一讲解:
虽然有一条读写磁盘的权限是动态权限,但是Android在5.0之后,给了App开发者几个不需要权限就可以读写的文件路径。因此,App其实可以不需要读写磁盘的权限,除非是一些第三方库需要,或者应用本身有着特殊路径的要求。 这里先来了解下android可使用的文件路径。
首先,App在手机上保存文件或者缓存数据时,应该遵守以下几点:
Android下有哪些文件目录:
1、应用私有存储(内置存储)
这里可以用来保存不能公开给其他应用的一些敏感数据如个人信息 Context.getFileDir() //对应路径/data/data/应用包名/files/
这里可以保存一些缓存文件如图片,当内置存储的空间不足时系统自动清除 Context.getCacheDir()//对应路径/data/data/应用包名/cache/
权限:不需要权限申请
以上是手机的内置存储,没有root过的手机是无法用文件管理器之类的工具查看的,而且这些数据也会随着用户卸载App而被一起删除。这两个目录其实就对应着设置->应用->你的App->存储空间下面的清除数据和清楚缓存。
2、应用扩展存储(SD卡)
对应路径是:
Context.getExternalFilesDir() //SDCard/Android/data/应用包名/files/
Context.getExternalCacheDir() //SDCard/Android/data/应用包名/cache/
权限: API<19,需要在清单文件写明权限 API>=19,不需要权限
既然是SD卡上的目录,那么是可以被其他的应用读取到的,所以这个目录下,不应该存放用户的敏感信息。同上面一样的,这里的文件会随着App卸载而被删除,也可以由用户手动在设置界面里面清除。
Android Lint是谷歌提供给Android开发者的静态代码检查工具。使用Lint对Android工程代码进行扫描和检查,可以发现代码潜在的问题,提醒开发及早修正。
当我们确定了调用了某些需要权限的代码的关键词时,我们可以通过自定义Lint检查器,在需要检查的模块的build.gradle加上自定义Lint检查的依赖。然后便可以通过执行Lint检查,检查结果会告知我们哪些地方出现了警告亦或错误。
当我们写好一套针对升级TargetSdkVersion的自定义lint检查,不仅能用在这次的任务中,还能够为以后开发新功能的开发人员进行提醒,以便开发人员都能意识到新的代码需要权限等安全问题。
代码中会出现明显的黄色的编译警告提示,如果我们定义了某种ISSUE为ERROR级别,那么会出现红色下划线。
这是这个模块的所有的Lint检查结果。
编写自定义lint可见参考文献。 大致以下几个步骤。
需要一个java工程,负责编写Lint检查的Detector
需要一个lib模块,负责引入(1)生成的jar包,将自己模块创建成aar形式
需要进行Lint检查的模块,依赖上(2)模块
1、明确哪些权限是需要动态申请的 我们可以查看下所有的权限官方文档,这里只列一下需要动态申请的。
这里需要知道的是权限和权限组的关系,当我们获取某个权限的时候,自动就能获取这个权限组的其他相关权限。
2、明确哪些权限是应用需要的
以云课堂为例,我们可以通过查看当前应用的信息,获知当前应用所需要的权限。如图,
以上这些表示写在清单文件中,并且用户有权利关闭的权限。
3、明确使用权限的时候会有哪些关键词操作
比如打开相机,一般的操作都是
Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
...
intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);
startActivityForResult(intent, requestCode);
再比如访问外置SDCard,
Environment.getExternalStorage()
再比如访问设备IMEI信息等,都需要先获取TelephoneManager类,
TelephonyManager mTelephonyMgr = (TelephonyManager)
context.getSystemService(Context.TELEPHONY_SERVICE);
等等。在得知这些关键词之后,我们就可以通过自定义Lint进行代码扫描,在找到这些地方后,在对应页面,使用PermissionsDispatcher库注解的方式,对需要权限的方法进行注解。
该第三方动态权限库有以下几个注解:
需要在调用需要权限方法的地方,改用使用XXXPermissionDispatcher类的checkXXX方法。 需要重写onRequestPermissionResult方法,调用XXXPermissionDispatcher类同名方法。
增加注解后的方法调用逻辑整理如下:
拍照或者在应用内使用应用安装器,都需要传递file://URI给外部应用。这里需要提供一个FileProvider,它是ContentProvider的子类,它使用了和内容提供器类似的机制来对数据进行保护,可以选择性地将封装过的Uri共享给外部,从而提高了应用的安全性。
首先,在清单文件中,注册这个provider,考虑到这个FileProvider会在多个模块中用到,因此我们将它放在了framework层,这里要注意authorities,每个应用都应该不一样,否则在这些应用无法在手机上共存!
//AndroidManifest.xml中
<provider
android:name="android.support.v4.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/edu_android_framework_filepath" />
</provider>
// res/xml配置
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!--Android 7.0 直接使用本地真实路径被认为是不安全的,会抛出FileUriExposedExCeption异常-->
<external-files-path name="external_file_dir" path="" />
<external-cache-path name="external_cache_dir" path="" />
<files-path name="file_dir" path="" />
<cache-path name="cache_dir" path="" />
</paths>
这里要注意的是做好版本兼容,否则适配了7.0,结果4.4以下的手机却不能用了。
//适配拍照功能
public static void adapterIntentForCamera(Context context, Intent intent, File imageTempFile) {
if (intent == null || imageTempFile == null) {
return;
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {//android 5.0 以上
Uri imageUri = FileProvider.getUriForFile(context, FileProviderUtil.FILE_AUTHORITY, imageTempFile);
intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);
intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
} else {
Uri imageUri = Uri.fromFile(imageTempFile);
intent.putExtra(MediaStore.EXTRA_OUTPUT, imageUri);
}
}
同理,应用安装也要做好版本适配。
/**
* 安装APK
* @param context 可以传Application Context 或者 Activity
* @param authority 建议传入 FileProviderUtil.FILE_AUTHORITY
*/
public static boolean installApk(Context context, String authority, File file) {
if (file == null) return false;
if (!file.exists()) return false;
NTLog.d(TAG, "installApk " + file.toString());
try {
Intent intent = new Intent(Intent.ACTION_VIEW);
Uri data;
// 判断版本大于等于7.0
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
// 给目标应用一个临时授权
intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
// 即是在清单文件中配置的authorities
data = FileProvider.getUriForFile(context, authority, file);
} else {
data = Uri.fromFile(file);
intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
}
intent.setDataAndType(data, "application/vnd.android.package-archive");
context.startActivity(intent);
} catch (Exception e) {
NTLog.e(TAG, e.getMessage());
return false;
}
return true;
}
从Android5.0开始,WebView默认不支持同时加载Https和Http混合模式,需要代码手动配置。
原本以为将所有读写文件的地方都改到不需要权限的路径下,就可以不需要WRITE_EXTERNAL_STORAGE
权限,结果云课堂之前的文件下载路径都是自定义外置存储卡路径,这里需要兼容之前用户的。
同理,一些第三方库,它们在初始化的时候,并未提供对诸如日志、缓存等文件的自定义文件路径配置。它们可能并未对6.0做对应的适配。这将导致轻则log日志丢失,重则功能失效的问题。需要我们开发人员与依赖的第三方库的开发人员进行相应的沟通,寻找最好的解决办法。
总的来说,在开始任务前,应该做好充分的调研,比如应该明确这次升级TargetSdkVersion会带来哪些风险,哪些改动,然后再进行方案的设计。而不是盲目的等待问题的出现,在进行修修补补。
再者,在修改每处代码,都应该记录下来,例如个人用户设置模块,扫二维码模块,安装应用更新包等等。在后期,和各个产品线的测试人员沟通的时候,就会轻松很多,应该给他们列出所有需要回归的地方。自己改的放心,测试测的放心,上线也会有更大的质量保障。
另外,在明确方案后,修改代码之前,还需要和各个产品端的策划沟通,让他们知晓你的改动方案,并接受这样的交互方式,不然到头来,苦的是自己。
最后,在调研、设计、修改的过程中,及时进行文档记录,最后输出一篇技术文档,能让组里其他的开发人员知晓这项改动,并在以后的开发中,面对同样的问题,有文档可参考。
这项任务,虽然没有包含什么业务需求,但是对于产品发展来说,确实必不可少的,谷歌最近刚推出了Android O 也就是 Android 26。如果我们仍旧保持原来的target版本,越到后面,升级面临的风险越大。等到谷歌推动我们改的时候,我们已经病入膏肓了,显得太被动。所以我们应该及时的更新应用的target版本,保持着比较新的代码,好的产品是坚实的基础技术服务所支持的,所以我们也应该推动类似这样的技术优化服务,不要畏惧风险。