Android MediaProvider 扫描优化

本文以 Android 9.0 为准

概述

《Android MediaProvider》 这篇文章中分析了 MediaProvider 的源码,不过当多个外部存储设备,并且存储大量文件时,MediaProvider 会存在扫描慢的情况,为了加快其扫描速度,修改策略为:

  1. 在第一次外部存储设备插入时,扫描数据存入数据库,拔出时不删除数据库
  2. 针对多个外部存储设备,存储多个数据表
  3. 第二次插入外部存储设备时,找到对应的数据库,清理数据库中的脏数据

当然,这种方式,在第一次扫描速度上,没有进行优化,也没有优化的空间,只能在第二次插拔上做文章,因为原生的 MediaProvider 会在拔出外部存储设备时删除数据库。

并且目前连接多个外部设备时,只操作一个外部数据库(数据库位置:/data/user/userid/com.android.providers.media/databases/external.db),这样就会导致数据量大,操作缓慢。

分析过程

通常来说,在 ContentProvider 中打开数据库的操作,应该在 onCreate() 方法中进行的 。

@Override
public boolean onCreate() {
    ...
    // DatabaseHelper缓存
    mDatabases = new HashMap<String, DatabaseHelper>();
    // 绑定内部存储数据库
    attachVolume(INTERNAL_VOLUME);
    ...
    String state = Environment.getExternalStorageState();
    if (Environment.MEDIA_MOUNTED.equals(state) ||
            Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
         // 如果已挂载外部存储,绑定外部存储数据库
        attachVolume(EXTERNAL_VOLUME);
    }
    ...
    return true;
}

可以看到打开数据库的位置在 attachVolume() 方法中:

/**
 * Attach the database for a volume (internal or external).
 * Does nothing if the volume is already attached, otherwise
 * checks the volume ID and sets up the corresponding database.
 *
 * @param volume to attach, either {@link #INTERNAL_VOLUME} or {@link #EXTERNAL_VOLUME}.
 * @return the content URI of the attached volume.
 */
private Uri attachVolume(String volume) {
    ...
    // Update paths to reflect currently mounted volumes
    // 更新路径以反映当前装载的卷
    updateStoragePaths();

    DatabaseHelper helper = null;
    synchronized (mDatabases) {
        helper = mDatabases.get(volume);
        // 判断是否已经attached过了
        if (helper != null) {
            if (EXTERNAL_VOLUME.equals(volume)) {
                // 确保默认的文件夹已经被创建在挂载的主要存储设备上,
                // 对每个存储卷只做一次这种操作,所以当用户手动删除时不会打扰
                ensureDefaultFolders(helper, helper.getWritableDatabase());
            }
            return Uri.parse("content://media/" + volume);
        }

        Context context = getContext();
        if (INTERNAL_VOLUME.equals(volume)) {
            // 如果是内部存储则直接实例化DatabaseHelper
            helper = new DatabaseHelper(context, INTERNAL_DATABASE_NAME, true,
                    false, mObjectRemovedCallback);
        } else if (EXTERNAL_VOLUME.equals(volume)) {
            // 如果是外部存储的操作,只获取主要的外部卷ID
            // Only extract FAT volume ID for primary public
            final VolumeInfo vol = mStorageManager.getPrimaryPhysicalVolume();
            if (vol != null) {
                final StorageVolume actualVolume = mStorageManager.getPrimaryVolume();
                final int volumeId = actualVolume.getFatVolumeId();

                // Must check for failure!
                // If the volume is not (yet) mounted, this will create a new
                // external-ffffffff.db database instead of the one we expect.  Then, if
                // android.process.media is later killed and respawned, the real external
                // database will be attached, containing stale records, or worse, be empty.
                // 数据库都是以类似 external-ffffffff.db 的形式命名的,
                // 后面的 8 个 16 进制字符是该 SD 卡 FAT 分区的 Volume ID。
                // 该 ID 是分区时决定的,只有重新分区或者手动改变才会更改,
                // 可以防止插入不同 SD 卡时数据库冲突。
                if (volumeId == -1) {
                    String state = Environment.getExternalStorageState();
                    if (Environment.MEDIA_MOUNTED.equals(state) ||
                            Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
                        // This may happen if external storage was _just_ mounted.  It may also
                        // happen if the volume ID is _actually_ 0xffffffff, in which case it
                        // must be changed since FileUtils::getFatVolumeId doesn't allow for
                        // that.  It may also indicate that FileUtils::getFatVolumeId is broken
                        // (missing ioctl), which is also impossible to disambiguate.
                        // 已经挂载但是sd卡是只读状态
                        Log.e(TAG, "Can't obtain external volume ID even though it's mounted.");
                    } else {
                        // 还没有挂载
                        Log.i(TAG, "External volume is not (yet) mounted, cannot attach.");
                    }

                    throw new IllegalArgumentException("Can't obtain external volume ID for " +
                            volume + " volume.");
                }

                // generate database name based on volume ID
                // 根据volume ID设置数据库的名称
                String dbName = "external-" + Integer.toHexString(volumeId) + ".db";
                // 创建外部存储数据库
                helper = new DatabaseHelper(context, dbName, false,
                        false, mObjectRemovedCallback);
                mVolumeId = volumeId;
            } else {
                // external database name should be EXTERNAL_DATABASE_NAME
                // however earlier releases used the external-XXXXXXXX.db naming
                // for devices without removable storage, and in that case we need to convert
                // to this new convention
                // 外部数据库名称应为EXTERNAL_DATABASE_NAME
                // 但是较早的版本对没有可移动存储的设备使用external-XXXXXXXX.db命名
                // 在这种情况下,我们需要转换为新的约定
                ...
                // 根据之前转换的数据库名,创建数据库
                helper = new DatabaseHelper(context, dbFile.getName(), false,
                        false, mObjectRemovedCallback);
            }
        } else {
            throw new IllegalArgumentException("There is no volume named " + volume);
        }
        // 缓存起来,标识已经创建过了数据库
        mDatabases.put(volume, helper);
        ...
    }

    if (EXTERNAL_VOLUME.equals(volume)) {
        // 给外部存储创建默认的文件夹
        ensureDefaultFolders(helper, helper.getWritableDatabase());
    }
    return Uri.parse("content://media/" + volume);
}

可以总结:

  • 如果此存储卷的 DatabaseHelper 已创建,则不执行任何操作。
  • 对于内部存储,数据存储在 internal.db 中;
  • 对于外部存储,获取主要的外部卷 ID,根据 ID 数据存储在 external-XXXXXXXX.db 中;
  • 对于较早版本中的外部存储,数据保存在 external.db 中,需要转换为新的约定;
  • 缓存 DatabaseHelper。

切入点是,对于外部存储,创建 DatabaseHelper 时,直接使用参数传递过来的 volumeId,打开 external-XXXXXXXX.db。

代码修改

一、MediaProvider

private Uri attachVolume(String volume) {
    ...
    if (INTERNAL_VOLUME.equals(volume)) {
        ...
    } else if (EXTERNAL_VOLUME.equals(volume)) {
        // Only extract FAT volume ID for primary public
        final VolumeInfo vol = mStorageManager.getPrimaryPhysicalVolume();
        if (vol != null) {
            final StorageVolume actualVolume = mStorageManager.getPrimaryVolume();
            final int volumeId = actualVolume.getFatVolumeId();
            ...
        } else {
            ...
        }
    } else {
        throw new IllegalArgumentException("There is no volume named " + volume);
    }
    ...
}

修改为

private Uri attachVolume(String volume) {
    ...
    if (INTERNAL_VOLUME.equals(volume)) {
        ...
    } else {
        final VolumeInfo vol = mStorageManager.getPrimaryPhysicalVolume();
        if (vol != null) {
            int volumeId = -1;
            try {
                volumeId = Integer.valueOf(volume);
            } catch (NumberFormatException e) {
                e.printStackTrace();
            }
            ...
        } else {
            ...
        }
    }
    ...
}

那么 attachVolume(String volume) ,对于外部存储传入的 volume 就不应是 EXTERNAL_VOLUME 了,而应该是外部存储的 volumeId。

volumeId 可以通过 StorageVolume 的 getFatVolumeId 方法获取,本质就是将 UUID 转换的 int 值,而 UUID 是一个长度为 9 的字符串,类似与 “57E9-73B0”这样。

继而需要找到 attachVolume(String volume) 调用的地方,修改参数为 volumeId,找到有两个地方调用了:

1、onCreate() 方法中:

String state = Environment.getExternalStorageState();
if (Environment.MEDIA_MOUNTED.equals(state) ||
        Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
    attachVolume(EXTERNAL_VOLUME);
}

可以通过 StorageManager.getStorageVolumes() 获取全部的 StorageVolume,因此修改为

final List<StorageVolume> storageVolumes = mStorageManager.getStorageVolumes();
String state;
for (StorageVolume storageVolume : storageVolumes) {
    state = storageVolume.getState();
    if (Environment.MEDIA_MOUNTED.equals(state) ||
            Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
        attachVolume(String.valueOf(storageVolume.getFatVolumeId()));
    }
}

2、在 insertInternal() 方法中:

private Uri insertInternal(Uri uri, int match, ContentValues initialValues,
                               ArrayList<Long> notifyRowIds) {
    ...
    switch (match) {
        case VOLUMES:
        {
            String name = initialValues.getAsString("name");
            Uri attachedVolume = attachVolume(name);
            if (mMediaScannerVolume != null && mMediaScannerVolume.equals(name)) {
                DatabaseHelper dbhelper = getDatabaseForUri(attachedVolume);
                if (dbhelper == null) {
                    Log.e(TAG, "no database for attached volume " + attachedVolume);
                } else {
                    dbhelper.mScanStartTime = SystemClock.currentTimeMicro();
                }
            }
            return attachedVolume;
        }
    }
    ...
}

这个方法调用的源头是在 MediaScannerService 中,下面分析扫描流程。

二、MediaScannerService

private void openDatabase(String volumeName) {
    try {
        ContentValues values = new ContentValues();
        values.put("name", volumeName);
        getContentResolver().insert(Uri.parse("content://media/"), values);
    } catch (IllegalArgumentException ex) {
        Log.w(TAG, "failed to open media database");
    }         
}

private void scan(String[] directories, String volumeName) {
    Uri uri = Uri.parse("file://" + directories[0]);
    // don't sleep while scanning
    mWakeLock.acquire();

    try {
        ContentValues values = new ContentValues();
        values.put(MediaStore.MEDIA_SCANNER_VOLUME, volumeName);
        Uri scanUri = getContentResolver().insert(MediaStore.getMediaScannerUri(), values);

        sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_STARTED, uri));

        try {
            if (volumeName.equals(MediaProvider.EXTERNAL_VOLUME)) {
                openDatabase(volumeName);
            }

            try (MediaScanner scanner = new MediaScanner(this, volumeName)) {
                scanner.scanDirectories(directories);
            }
        } catch (Exception e) {
            Log.e(TAG, "exception in MediaScanner.scan()", e);
        }

        getContentResolver().delete(scanUri, null, null);

    } finally {
        sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_FINISHED, uri));
        mWakeLock.release();
    }
}

由于 volumeName 不再是 EXTERNAL_VOLUME,修改 openDatabase() 执行的判断语句。

if (!volumeName.equals(MediaProvider.INTERNAL_VOLUME)) {
    openDatabase(volumeName);
}

注意在调用 scan() 方法的地方,传入的 directories 根据 volume 做了一个判断,注意也要修正过来。

private final class ServiceHandler extends Handler {
    @Override
    public void handleMessage(Message msg) {
        ...
        String volume = arguments.getString("volume");
        String[] directories = null;

        if (MediaProvider.INTERNAL_VOLUME.equals(volume)) {
            // scan internal media storage
            directories = new String[] {
                    Environment.getRootDirectory() + "/media",
                    Environment.getOemDirectory() + "/media",
                    Environment.getProductDirectory() + "/media",
            };
        }
        // 这里的判断要修改 start
        /*else if (MediaProvider.EXTERNAL_VOLUME.equals(volume)) {*/
        else {
        // 这里的判断要修改 end
            // scan external storage volumes
            if (getSystemService(UserManager.class).isDemoUser()) {
                directories = ArrayUtils.appendElement(String.class,
                        mExternalStoragePaths,
                        Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
            } else {
                directories = mExternalStoragePaths;
            }
        }

        if (directories != null) {
            if (false) Log.d(TAG, "start scanning volume " + volume + ": "
                    + Arrays.toString(directories));
            scan(directories, volume);
            if (false) Log.d(TAG, "done scanning volume " + volume);
        }
        ...
    }
}

找到 volumeName 赋值的源头在 MediaScannerReceiver 中。

这里猜测,存在一个优化点,扫描的路径 directories,源码中是全部的外部存储路径,为了节省性能,directories 可以根据外部设备的 StorageVolume 的 getPath() 方法,进行单个路径扫描。如果要处理的话,我们在这里需要 MediaScannerReceiver 将路径传递过来。(还未验证,如果不行的话,就不要对 directories 进行修改了)。

三、MediaScannerReceiver

@Override
public void onReceive(Context context, Intent intent) {
    final String action = intent.getAction();
    final Uri uri = intent.getData();
    if (Intent.ACTION_BOOT_COMPLETED.equals(action)) {
        ...
    } else if (Intent.ACTION_LOCALE_CHANGED.equals(action)) {
        ...
    } else {
        if (uri.getScheme().equals("file")) {
            ...
            if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) {
                // scan whenever any volume is mounted
                scan(context, MediaProvider.EXTERNAL_VOLUME);
            } 
            ...
        }
    }
}

所以切入点就是,在接收 ACTION_MEDIA_MOUNTED 广播的位置,获取到 Uri,传入 scan() 方法。

scan(context, uri.toString());

最后在回到 MediaScannerService 修改接收的参数 "volume"。

四、MediaScannerService

private final class ServiceHandler extends Handler {
    @Override
    public void handleMessage(Message msg) {
        ...
        String volume = arguments.getString("volume");
        String[] directories = null;

        if (MediaProvider.INTERNAL_VOLUME.equals(volume)) {
            // scan internal media storage
            directories = new String[] {
                    Environment.getRootDirectory() + "/media",
                    Environment.getOemDirectory() + "/media",
                    Environment.getProductDirectory() + "/media",
            };
        }
        // 这里需要修改 start
        /*else if (MediaProvider.EXTERNAL_VOLUME.equals(volume)) {
            // scan external storage volumes
            if (getSystemService(UserManager.class).isDemoUser()) {
                directories = ArrayUtils.appendElement(String.class,
                        mExternalStoragePaths,
                        Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
            } else {
                directories = mExternalStoragePaths;
            }
        }*/
        else {
            boolean isSinglePath = false;// 标记单个路径扫描
            String[] singleStoragePath = new String[1];
            try {
                // Uri 转换为 File
                File file = new File(new URI(volume));
                // 根据 File 获取 StorageVolume
                StorageVolume storageVolume = mStorageManager.getStorageVolume(file);
                if (storageVolume != null) {
                    // 重新赋值 volume 为 volumeId
                    volume = String.valueOf(storageVolume.getFatVolumeId());
                    singleStoragePath[0] = storageVolume.getPath();
                    isSinglePath = true;
                }
            } catch (URISyntaxException e) {
                e.printStackTrace();
            }
            if (getSystemService(UserManager.class).isDemoUser()) {
                if (isSinglePath) {
                    directories = ArrayUtils.appendElement(String.class,
                            singleStoragePath,
                            Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
                } else {
                    directories = ArrayUtils.appendElement(String.class,
                            mExternalStoragePaths,
                            Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
                }
            } else {
                if (isSinglePath) {
                    directories = singleStoragePath;
                } else {
                    directories = mExternalStoragePaths;
                }
            }
        }
        // 这里需要修改 end

        if (directories != null) {
            if (false) Log.d(TAG, "start scanning volume " + volume + ": "
                    + Arrays.toString(directories));
            scan(directories, volume);
            if (false) Log.d(TAG, "done scanning volume " + volume);
        }
        ...
    }
}

五、ContentResolver

所有操作外部存储的地方,增删改查使用的 URI 中的 path 都要修改为 volumeId,才能正常运行,而 MediaStore.Audio.Media.EXTERNAL_CONTENT_URI 就不能使用了,看下 URI 的组成:

public static final Uri EXTERNAL_CONTENT_URI = getContentUri("external");

public static Uri getContentUri(String volumeName) {
    return Uri.parse(CONTENT_AUTHORITY_SLASH + volumeName +
            "/audio/media");
}

再看下 MediaProvider 中是根据 URI 的 path 中 volumeName 去获取 DatabaseHelper:

private DatabaseHelper getDatabaseForUri(Uri uri) {
    synchronized (mDatabases) {
        if (uri.getPathSegments().size() >= 1) {
            return mDatabases.get(uri.getPathSegments().get(0));
        }
    }
    return null;
}

所以开发者使用中,只要传入 volumeId 即可完成闭环。

MediaStore.Audio.Media.getContentUri(volumeId)

问题点 :

1、mVolumeId 需要处理吗,添加多个到 MatrixCursor?

// Used temporarily (until we have unique media IDs) to get an identifier
// for the current sd card, so that the music app doesn't have to use the
// non-public getFatVolumeId method
if (table == FS_ID) {
    MatrixCursor c = new MatrixCursor(new String[] {"fsid"});
    c.addRow(new Integer[] {mVolumeId});
    return c;
}

目前没有处理,可以正常运行,先不处理该逻辑。


参考资料:
android 添加或者取消对于某种媒体文件格式的支持
Android源码个个击破之-多媒体扫描
Android媒体扫描详细解析之一
Android媒体扫描详细解析之二

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章