二、Android存储优化

1,399 阅读16分钟

前言

最近在看项目中的存储优化,除了数据库存储方面,就是网络数据存储优化,一般网络传输都会用到JSON、Protocol Buffers ,确实也没什么好优化的,只能检查一下使用过程中是否会出现问题,顺带了解了一下存储相关的知识点,文末有Android 10适配整理代码。

参考资料

极客时间Android开发高手课

理解安全机制

Android安全模型之Android安全机制

应用沙盒

目录



一、Android沙盒(隔离机制)

对于IPhone而言,App就是一个沙盒,你只能看到App外在的基本信息,看不到里面到底在干什么。

在Android系统中,应用(通常)都在一个独立的沙箱中运行,即每一个Android应用程序都在它自己的进程中运行,都拥有一个独立的Dalvik虚拟机实例。系统会给每一个应用程序分配一个唯一的UID,同时也会为该应用程序下所有的文件与所有的操作都配置相同的权限,只有相同UIID的应用程序才能对这些文件进行读写操作,当然这一切都除了root权限的用户。Android采用了Linuxde UID/GID隔离机制,在Andoird平台上,UID也称为AID。

Android从Linux继承了已经深入人心的类Unix进程隔离机制与最小权限原则,同时结合移动终端的具体应用特点,进行了许多有益的改进与提升。具体而言,进程以隔离的用户环境运行,不能相互干扰,比如发送信号或者访问其他进程的内存空间

总的来说,进程沙箱隔离机制有两点

  • 进程沙箱隔离机制,使得Android应用程序在安装时被赋予独特的用户标识(UID),并永久保持。应用程序及其运行的Dalvik虚拟机运行在独立的Linux进程空间,与其它应用程序完全隔离。

  • 在特殊情况下,进程间还可以存在相互信任关系。如源自同一开发者或同一开发机构的应用程序,通过Android提供的共享UID(Shared UserId)机制,使得具备信任关系的应用程序可以运行在同一进程空间。

二、Android分区

Android 系统可以通过 /proc/partitions 或者 df 命令来查看的各个分区情况,下图是 Nexus 6 中 df 命令的运行结果。


分区简单来说就是将设备中的存储划分为一些互不重叠的部分,每个部分都可以单独格式化,用作不同的目的。这样系统就可以灵活的针对单独分区做不同的操作,例如在系统还原(recovery)过程,我们不希望会影响到用户存储的数据。

不同的分区可以使用不同的文件系统,每个分区都非常独立。

三、Android存储安全

除了数据分区隔离,存储安全也是Android比较重要的一部分,开头说的Android沙盒其实也算是存储安全的一部分,可以归纳为权限控制。

1、权限控制

Android的每个应用都在自己的应用沙盒里运行,没有权限将不能访问系统的一些保护文件,同时也能做到A应用不能访问B应用的数据。

在 Android 4.3 引入了SELinux(Security Enhanced Linux)机制进一步定义 Android 应用沙盒的边界。那它有什么特别的呢?它的作用是即使我们进程有 root 权限也不能为所欲为,如果想在 SELinux 系统中干任何事情,都必须先在专门的安全策略配置文件中赋予权限。

2、数据加密

Android 有两种设备加密方法:全盘加密和文件级加密。全盘加密是在 Android 4.4 中引入的,并在 Android 5.0 中默认打开。它会将 /data 分区的用户数据操作加密 / 解密,对性能会有一定的影响,但是新版本的芯片都会在硬件中提供直接支持。

我们知道,基于文件系统的加密,如果设备被解锁了,加密也就没有用了。所以 Android 7.0 增加了基于文件的加密。在这种加密模式下,将会给每个文件都分配一个必须用用户的 passcode 推导出来的密钥。特定的文件被屏幕锁屏之后,直到用户下一次解锁屏幕期间都不能访问。

四、Android常见的存储方式

在我们选择数据存储方法的时候呢,一般会考虑到 耗时、内存、开发成本、兼容性以及数据的正确性,下面是几种常用的存储方式

1、SharePreferences

  • SharePreferences一般用来存储一些比较小的键值对集合,不要使用它来存储过于复杂的数据,例如 HTML、JSON 等。而且 SharedPreference 的文件存储性能与文件大小相关,每个 SP 文件不能过大

  • SharedPreferences对应的commit或者apply方法,区别在于同步写入和异步写入以及是否需要返回值,在不需要返回值的情况下,apply可以提高性能。注意apply会将数据原子提交到内存,然后异步提交到硬件磁盘,commit是同步提交到磁盘,commit对多并发场景不友好。至于是否使用哪个,完全看场景。

  • 加载缓慢。SharedPreferences 文件的加载使用了异步线程,而且加载线程并没有设置线程优先级,如果这个时候主线程读取数据就需要等待文件加载线程的结束。这就导致出现主线程等待低优先级线程锁的问题,比如一个 100KB 的 SP 文件读取等待时间大约需要 50~100ms,建议提前用异步线程预加载启动过程用到的 SP 文件。

  • 全量写入。无论是调用 commit() 还是 apply(),即使我们只改动其中的一个条目,都会把整个内容全部写到文件。而且即使我们多次写入同一个文件,SP 也没有将多次修改合并为一次,这也是性能差的重要原因之一。

  • 卡顿。由于提供了异步落盘的 apply 机制,在崩溃或者其他一些异常情况可能会导致数据丢失。所以当应用收到系统广播,或者被调用 onPause 等一些时机,系统会强制把所有的 SharedPreferences 对象数据落地到磁盘。如果没有落地完成,这时候主线程会被一直阻塞。这样非常容易造成卡顿,甚至是 ANR,从线上数据来看 SP 卡顿占比一般会超过 5%。

2、MMKV

MMKV 官网对原理步骤解释也很到位,使用了文件锁来保证跨进程的安全,而且还能对SP数据进行转移


MMKV的写入逻辑是:当我们覆盖某个值的时候,它并不会立即删除前面的值,会保留,然后每个key,value有存储限制,当触发存储限制的时候,才会执行删除,这样即使我们频繁的覆盖,也不会引起太多的性能损耗

MMKV Log 默认转发到系统Log上,可以实现MMKVHandler接口,自己操作

增量KV对象序列化后,数据直接append到内存末尾,这样同一个key会有新旧若干份数据,最新的数据在最后,然后每次程序启动第一次打开mmkv时,不断用后读入的value替换之前的值,用以保持数据是最新的。

以内存pagesize为单位申请空间,空间用尽前是append模式,当append到文件末尾时,进行文件重整,key排重,排重后空间不够用,将文件扩大一倍,直到空间足够。(中间会遇到写指针增长,内存重整,内存增长,让其它进程可以感知这三种情况)

这是以前总结的,也忘了是从哪里来的了

  1. 发现他们在内存重组的时候是直接在原始文件中写重组过后的数据,并且重组完成之后没有sync, 存在会有很大的风险。虽说mmap利用操作系统的机制来保证即使进程被杀,也能写数据, 但首先得保证把所有要写的数据写进mmap映射的内存中,如果在写完成之前进程就已经被杀了,那就有可能出现mmap中的数据是错误的,即使完成了写mmap内存,如果在操作系统将数据写入硬盘前突然关机,那也有可能丢失数据,造成最终的数据损坏。

  2. 而SharedPreferences的写操作,首先是将原始文件备份,再写入所有数据,只有写入成功了,并且通过sync完成落盘后,才会将Backup文件删除。如果在写入过程中进程被杀,或者关机,进程再次起来的时候发现存在Backup文件,就将Backup文件重命名为原始文件,原本未完成写入的文件就直接丢弃来,这样最多也就是未完成写入的数据丢失,文件是不会损坏的

  3. 所以可以认为SharedPreferences的写入在单进程中是安全的,也正是因为back的机制,导致多进程可能会丢失新写入的数据。

  4. 从MMKV的github上看到数据有效性的说明,在ios每天存在超过70万次的数据校验失败, 就是因为写数据实际是不安全的导致的

  5. 文件损坏之后支持recover模式,从文件中尽力而为的修复数据。针对mmkv的recover模式,也正是大家担心的一个点。从mmkv源码来看,在crc校验失败后默认选择丢弃数据。recover模式作为一个可选模式,也没有看到有什么恢复数据的措施,只是仍然强行decode数据,这样的话理论上decode出来的数据就可能是错误的,

3、ContentProvider

用这个作数据存储还是比较少的,一般会用来做启动优化,因为ContentProvider的onCreate在Application的attachBaseContext之后,在Application的onCreate方法执行之前,结合它的 multiprocess 属性,可以根据场景,将Application中初始化的工作放到ContentProvider的onCreate中执行。

而且ContentProvider在跨进程数据传递时,利用了Android的Binder和匿名共享内存机制,在传输过程中,结果数据并不需要跨进程传输,而是在不同进程中通过传输匿名共享内存文件描述符来操作同一块内存,当然这并不适用于小量数据传输,因为它内部会通过Binder来传递数据。

至于ContentProvider的安全性,内部并不能保证它的安全性,需要我们校验一下数据或者文件的合法性,例如在Intent传递参数的时候,其实也应该校验下合法性。


五、Android常见的存储优化

数据存储,数据是以什么格式存储到文件或者内存里?

1、Serializable

Java 对象序列化

通过Serializable序列化,会产生很多临时变量,而且使用到了反射,整个过程可能还会涉及GC操作,所以一般建议如果不是持久存储,能用Parcelable,尽量用Parcelable

private void writeFieldValues(Object obj, ObjectStreamClass classDesc)  {
    for (ObjectStreamField fieldDesc : classDesc.fields()) {
        ...
        Field field = classDesc.checkAndGetReflectionField(fieldDesc);
        ...

一些注意事项在上述链接中有详细说明,serialVersionUID如果在反序列化时改变了,会导致InvalidClassException。还有反序列化,是不会执行构造函数的,可以看android 6.0的 ObjectOutputStream 实现,是通过流来创建对象的。

2、Parcelable

源码Parcel.cpp

首先需要知道的是Parcelable只会在内存中进行序列化,并不会将数据存储到磁盘里,它核心的作用就是为了解决 Android 中大量跨进程通信的性能问题。相对于Serializable,性能更优,写法复杂,但是有AS插件,可以自动生成,完全不必担心。

但是使用Parcelable有两个问题,你并不知道是不是每个系统版本的Parcel源码实现都一样的,存储的数据结构升级了,添加了字段,还得相应做修改(笔者刚开始就由于修改了数据格式,和缓存的匹配不一致问题)

3、JSON

最近Fastjson爆出的漏洞还是不少,移动端还是能安心使用,主要在于服务端吧。下面看一下对应的耗时情况,在选择时可以参考下图,可以看出Fastjson是很优秀的。


4、Protocol Buffers、FlatBuffers

在网络优化时,一般会有文章会提到这个,个人觉得这个性能好,编码复杂,也就是效果好,但是使用成本比较大。

protocol-buffers 编码规则

FlatBuffers 体验

5、日志存储

日志存储,如果不使用第三方SDK,例如XLog,怎么实现?

需要保证三点 数据的 正确性、安全、不丢失

常规的做法,一般都会打开文件流,写到一定大小的buffer里面,buffer满了,写到文件里,想要安全的话,就将数据加密一下,但是保证不丢失,这个在大量文件的时候,不一定能保证。

XLog使用全解析-知乎 使用到了mmap 内存映射,连数据拷贝次数都减少了一次


6、数据库SQLite

现在安卓上可用的数据库框架还是比较多的,Realm、Room、LevelDB、GreenDao and so on,大多都是ORM(对象关系映射),我们都不用关心数据库底层如何实现的(操作SQLite),维护好我们的类就行。

对于SQlite,笔者能力有限,水比较深,慢慢消化,这里作为笔记记录。

SQlite锁机制

SQlite封锁机制

SQlite索引优化原理 类似于SparseArray,有点相同的思想,

MySQL 索引背后的数据结构及算法原理

微信全文搜索优化之路

SQLite优化点

  • 慎用“select*”,需要使用多少列,就选取多少列

  • 对于 blob 或超大的 Text 列,可能会超出一个页的大小,导致出现超大页。建议将这些列单独拆表,或者放到表字段的后面。

  • 定期整理或者清理无用或可删除的数据,例如朋友圈数据库会删除比较久远的数据,如果用户访问到这部分数据,重新从网络拉取即可。

  • 使用 StringBuilder 代替 String

  • 少用 cursor.getColumnIndex,cursor.getColumnIndex 的时间消耗跟 cursor.getInt 相差不大。

  • Android 中数据不多时表查询可能耗时不多,不会导致 ANR,不过大于 100ms 时同样会让用户感觉到延时和卡顿,可以放在线程中运行,但 sqlite 在并发方面存在局限,多线程控制较麻烦,这时候可使用单线程池,在任务中执行 db 操作,通过 handler 返回结果和 UI 线程交互,既不会影响 UI 线程,同时也能防止并发带来的异常。

  • SQLiteOpenHelper 保持单例

六、Android Q 存储适配代码汇总

1、android target设置为10,新的存储模式不允许随意新建共享文件,可通过配置android:requestLegacyExternalStorage="true"来请求使用旧的存储模式。新的存储模式只能通过MediaStore API来访问照片、视频和音乐文件
​
android 11 已确认该属性无效,必需适配
​
2.适配时需要判断是否是android 10并且兼容模式没开或者没生效
​
// 使用Environment.isExternalStorageLegacy()来检查APP的运行模式
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && 
        !Environment.isExternalStorageLegacy()) {
    }
​
​
3.安装APK适配
    /**
     * 安装APK
     *
     * @param context  上下文
     * @param filePath 公有目录或者其它目录 公有目录记得通过 {@link #isFileExists} 判断文件是否存在
     */
    public static void installApk(Context context, String filePath) {
        Intent intent = new Intent(Intent.ACTION_VIEW);
        File apkFile = new File(filePath);
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
​
            ContentResolver resolver = context.getContentResolver();
            ContentValues values = new ContentValues();
            values.put(MediaStore.Downloads.DISPLAY_NAME, apkFile.getName());
            values.put(MediaStore.Downloads.MIME_TYPE, "application/vnd.android.package-archive");
            values.put(MediaStore.Downloads.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
​
            Uri insertUri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
            OutputStream os = null;
            InputStream is = null;
            try {
                os = resolver.openOutputStream(insertUri);
                is = new FileInputStream(apkFile);
            } catch (FileNotFoundException e) {
​
            }
​
            byte[] data = new byte[1024];
            int len = -1;
​
            try {
                while ((len = is.read(data)) >= 0) {
                    os.write(data, 0, len);
                }
            } catch (IOException e) {
​
            } finally {
                if (os != null) {
                    try {
                        os.flush();
                        os.close();
                    } catch (IOException e) {
​
                    }
                }
                if (is != null) {
                    try {
                        is.close();
                    } catch (IOException e) {
​
                    }
                }
​
            }
​
            if (insertUri == null) {
                return;
            }
            //适配Android Q,mFilePath,上述有相关代码
            intent.setDataAndType(insertUri, "application/vnd.android.package-archive");
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
​
        } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
​
            intent.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
            Uri contentUri = FileProvider.getUriForFile(context, context.getPackageName() + ".provider", apkFile);
            intent.setDataAndType(contentUri, "application/vnd.android.package-archive");
​
        } else {
​
            intent.setDataAndType(Uri.fromFile(apkFile), "application/vnd.android.package-archive");
            intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        }
        context.startActivity(intent);
    }
    
4.判断公有目录是否存在适配
​
 /**
     * 判断公有目录文件是否存在
     *
     * @param context
     * @param file
     * @return
     */
    public static boolean isFileExists(Context context, File file) {
        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
            return file.exists();
        }
        return isAndroidQFileExists(context, file);
    }
​
    private static boolean isAndroidQFileExists(Context context, File file) {
        AssetFileDescriptor afd = null;
        ContentResolver cr = context.getContentResolver();
        try {
            Uri uri = Uri.fromFile(file);
            afd = cr.openAssetFileDescriptor(uri, "r");
            if (afd == null) {
                return false;
            } else {
                close(afd);
            }
        } catch (FileNotFoundException e) {
            e.printStackTrace();
            return false;
        } finally {
            close(afd);
        }
        return true;
    }
​
    private static void close(AssetFileDescriptor afd) {
        if (afd == null) {
            return;
        }
        try {
            afd.close();
        } catch (IOException e) {
​
        }
    }
    
5.拍照适配
    /**拍照返回可以直接设置ImageView.setImageURI(uri)显示**/
    public static void takePhoto(Activity context) {
​
​
        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        takePictureIntent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
​
        if (takePictureIntent.resolveActivity(context.getPackageManager()) != null) {
​
            File takeImageFile = null;
            if (Utils.existSDCard()) {
                takeImageFile = new File(Environment.getExternalStorageDirectory(), "/DCIM/camera/");
            } else {
                takeImageFile = Environment.getDataDirectory();
            }
            takeImageFile = createFile(takeImageFile, "IMG_", ".jpg");
​
​
            Uri photoUri = null;
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
                photoUri = createImageUri(context);
                takePictureIntent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
            } else {
​
                // 默认情况下,即不需要指定intent.putExtra(MediaStore.EXTRA_OUTPUT, uri);
                // 照相机有自己默认的存储路径,拍摄的照片将返回一个缩略图。如果想访问原始图片,
                // 可以通过dat extra能够得到原始图片位置。即,如果指定了目标uri,data就没有数据,
                // 如果没有指定uri,则data就返回有数据!
​
​
                if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.M) {
                    photoUri = Uri.fromFile(takeImageFile);
                } else {
                    /**
                     * 7.0 调用系统相机拍照不再允许使用Uri方式,应该替换为FileProvider
                     * 并且这样可以解决MIUI系统上拍照返回size为0的情况
                     */
                    photoUri = FileProvider.getUriForFile(context, context.getPackageName() + ".provider", takeImageFile);
                    //加入uri权限 要不三星手机不能拍照
                    List<ResolveInfo> resInfoList = context.getPackageManager().queryIntentActivities
                            (takePictureIntent, PackageManager.MATCH_DEFAULT_ONLY);
                    for (ResolveInfo resolveInfo : resInfoList) {
                        String packageName = resolveInfo.activityInfo.packageName;
                        context.grantUriPermission(packageName, photoUri, Intent
                                .FLAG_GRANT_WRITE_URI_PERMISSION | Intent.FLAG_GRANT_READ_URI_PERMISSION);
                    }
                }
​
            }
            cameraUri = photoUri;
            takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoUri);
            context.startActivityForResult(takePictureIntent, TAKE_PICTURE_REQUEST_CODE);
        }
    }
​
    private static Uri createImageUri(Context context) {
        if (Utils.existSDCard()) {
            return context.getContentResolver().insert(MediaStore.Images.Media.EXTERNAL_CONTENT_URI, new ContentValues());
        }
        return context.getContentResolver().insert(MediaStore.Images.Media.INTERNAL_CONTENT_URI, new ContentValues());
    }
​
    /**
     * 根据系统时间、前缀、后缀产生一个文件
     */
    public static File createFile(File folder, String prefix, String suffix) {
        if (!folder.exists() || !folder.isDirectory()) folder.mkdirs();
        SimpleDateFormat dateFormat = new SimpleDateFormat("yyyyMMdd_HHmmss", Locale.CHINA);
        String filename = prefix + dateFormat.format(new Date(System.currentTimeMillis())) + suffix;
        return new File(folder, filename);
    }
    
6.查找图片并展示适配
    /**andorid Q 类似写法**/
    public static List<Uri> getLocalImages(Context context){
        List<Uri> result = new ArrayList<>();
        Cursor cursor = context.getContentResolver().query(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,null,null,null,MediaStore.MediaColumns.DATE_ADDED+" desc");
        if(cursor != null){
            while (cursor.moveToNext()){
                long id = cursor.getLong(cursor.getColumnIndexOrThrow(MediaStore.MediaColumns._ID));
                Uri uri = ContentUris.withAppendedId(MediaStore.Images.Media.EXTERNAL_CONTENT_URI,id);
                result.add(uri);
            }
        }
        cursor.close();
        return  result;
    }
    
7.下载文件适配
 /**文件下载完可以用该uri进行android 10 安装**/
 public static void downloadFile(Context context, InputStream inputStream, String fileName) {
        if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
            ContentResolver resolver = context.getContentResolver();
            ContentValues values = new ContentValues();
            //DISPLAY_NAME 可以不写
            values.put(MediaStore.MediaColumns.DISPLAY_NAME, fileName);
            values.put(MediaStore.MediaColumns.RELATIVE_PATH, Environment.DIRECTORY_DOWNLOADS);
            Uri uri = resolver.insert(MediaStore.Downloads.EXTERNAL_CONTENT_URI, values);
            OutputStream os = null;
           
            try {
                os = resolver.openOutputStream(uri);
               
            } catch (FileNotFoundException e) {
​
            }
​
            byte[] data = new byte[1024];
            int len = -1;
​
            try {
                while ((len = inputStream.read(data)) >= 0) {
                    os.write(data, 0, len);
                }
            } catch (IOException e) {
​
            } finally {
                if (os != null) {
                    try {
                        os.flush();
                        os.close();
                    } catch (IOException e) {
​
                    }
                }
                if (inputStream != null) {
                    try {
                        inputStream.close();
                    } catch (IOException e) {
​
                    }
                }
​
            }
        }else{
            // < Q 适配
        }
    }
​


笔记二完结