Android MediaProvider

本文以 Android 9.0 為準(zhǔn)

Android 系統(tǒng)提供了對多媒體的統(tǒng)一處理機(jī)制叁征,通過一套良好的框架實(shí)現(xiàn)了多媒體信息的掃描码泞、存儲谁榜、讀取幅聘。用戶可以基于這套框架非常方便的對多媒體信息進(jìn)行處理,這套框架主要包含了三部分:

  • MediaScannerReceiver:多媒體掃描廣播接收者窃植,繼承 BroadcastReceiver帝蒿,主要響應(yīng)APP發(fā)送的廣播命令,并開啟 MediaScannerService 執(zhí)行掃描工作巷怜。
  • MediaScannerService:多媒體掃描服務(wù)葛超,繼承 Service,主要是處理 APP 發(fā)送的請求延塑,要用到 Framework 中的 MediaScanner 來共同完成具體掃描工作绣张,并獲取媒體文件的 metadata,最后將數(shù)據(jù)寫入或刪除 MediaProvider 提供的數(shù)據(jù)庫中关带。
  • MediaProvider:多媒體內(nèi)容提供者侥涵,繼承 ContentProvider,主要是負(fù)責(zé)操作數(shù)據(jù)庫宋雏,并提供給別的程序 insert芜飘、query、delete磨总、update 等操作嗦明。

本文就從上面三部分作為入口,分析它們是如何工作的蚪燕,如何對設(shè)備上的多媒體進(jìn)行掃描娶牌,如何將多媒體信息進(jìn)行存儲奔浅,用戶如何讀取、修改多媒體信息裙戏?


1. 如何調(diào)用 MediaScannerService?

1.1 MediaScannerActivity

我們可以從 Android 自帶的 Dev Tools 中的 MediaScannerActivity 入手厕诡,看看它是如何掃描多媒體累榜。
/development/apps/Development/src/com/android/development/MediaScannerActivity.java

package com.android.development;

public class MediaScannerActivity extends Activity
{
    private TextView mTitle;

    /** Called when the activity is first created or resumed. */
    @Override
    public void onCreate(Bundle icicle) {
        super.onCreate(icicle);

        setContentView(R.layout.media_scanner_activity);

        IntentFilter intentFilter = new IntentFilter(Intent.ACTION_MEDIA_SCANNER_STARTED);
        intentFilter.addAction(Intent.ACTION_MEDIA_SCANNER_FINISHED);
        intentFilter.addDataScheme("file");
        registerReceiver(mReceiver, intentFilter);

        mTitle = (TextView) findViewById(R.id.title);
    }

    /** Called when the activity going into the background or being destroyed. */
    @Override
    public void onDestroy() {
        unregisterReceiver(mReceiver);
        super.onDestroy();
    }

    private final BroadcastReceiver mReceiver = new BroadcastReceiver() {
        @Override
        public void onReceive(Context context, Intent intent) {
            if (intent.getAction().equals(Intent.ACTION_MEDIA_SCANNER_STARTED)) {
                mTitle.setText("Media Scanner started scanning " + intent.getData().getPath());
            }
            else if (intent.getAction().equals(Intent.ACTION_MEDIA_SCANNER_FINISHED)) {
                mTitle.setText("Media Scanner finished scanning " + intent.getData().getPath());
            }
        }
    };

    public void startScan(View v) {
        sendBroadcast(new Intent(Intent.ACTION_MEDIA_MOUNTED, Uri.parse("file://"
                + Environment.getExternalStorageDirectory())));

        mTitle.setText("Sent ACTION_MEDIA_MOUNTED to trigger the Media Scanner.");
    }
}

主要做了兩件事:

  • 注冊掃描開始和結(jié)束的廣播,用來展示掃描狀態(tài)灵嫌;
  • 在點(diǎn)擊事件中壹罚,發(fā)送了 ACTION_MEDIA_MOUNTED 廣播。

那么系統(tǒng)肯定存在一個的接收者寿羞,在收到 ACTION_MEDIA_MOUNTED 后進(jìn)行掃描猖凛,這就是 MediaScannerReceiver。

1.2 MediaScannerReceiver

首先關(guān)注 AndroidManifest绪穆,對接受的廣播一目了然辨泳。
/packages/providers/MediaProvider/AndroidManifest.xml

<receiver android:name="MediaScannerReceiver">
    <intent-filter>
        <action android:name="android.intent.action.BOOT_COMPLETED" />
        <action android:name="android.intent.action.LOCALE_CHANGED" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.MEDIA_MOUNTED" />
        <data android:scheme="file" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.MEDIA_UNMOUNTED" />
        <data android:scheme="file" />
    </intent-filter>
    <intent-filter>
        <action android:name="android.intent.action.MEDIA_SCANNER_SCAN_FILE" />
        <data android:scheme="file" />
    </intent-filter>
</receiver>

/packages/providers/MediaProvider/src/com/android/providers/media/MediaScannerReceiver.java

package com.android.providers.media;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.provider.MediaStore;
import android.util.Log;

import java.io.File;
import java.io.IOException;

public class MediaScannerReceiver extends BroadcastReceiver {
    private final static String TAG = "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)) {
            // 開機(jī)廣播,只處理內(nèi)部存儲
            scan(context, MediaProvider.INTERNAL_VOLUME);
        } else if (Intent.ACTION_LOCALE_CHANGED.equals(action)) {
            // 處理系統(tǒng)語言變換
            scanTranslatable(context);
        } else {
            if (uri.getScheme().equals("file")) {
                // 處理外部存儲
                String path = uri.getPath();
                String externalStoragePath = Environment.getExternalStorageDirectory().getPath();
                String legacyPath = Environment.getLegacyExternalStorageDirectory().getPath();

                try {
                    path = new File(path).getCanonicalPath();
                } catch (IOException e) {
                    Log.e(TAG, "couldn't canonicalize " + path);
                    return;
                }
                if (path.startsWith(legacyPath)) {
                    path = externalStoragePath + path.substring(legacyPath.length());
                }

                Log.d(TAG, "action: " + action + " path: " + path);
                if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) {
                    // 每當(dāng)掛載外部存儲時
                    scan(context, MediaProvider.EXTERNAL_VOLUME);
                } else if (Intent.ACTION_MEDIA_SCANNER_SCAN_FILE.equals(action) &&
                        path != null && path.startsWith(externalStoragePath + "/")) {
                    // 掃描單個文件玖院,并且路徑是在外部存儲路徑下
                    scanFile(context, path);
                }
            }
        }
    }

    // 掃描內(nèi)部或者外部存儲,根據(jù)volume進(jìn)行區(qū)分
    private void scan(Context context, String volume) {
        Bundle args = new Bundle();
        args.putString("volume", volume);
        context.startService(new Intent(context, MediaScannerService.class).putExtras(args));
    }

    // 掃描單個文件,不可以是文件夾
    private void scanFile(Context context, String path) {
        Bundle args = new Bundle();
        args.putString("filepath", path);
        context.startService(new Intent(context, MediaScannerService.class).putExtras(args));
    }

    // 掃描可轉(zhuǎn)換語言的多媒體
    private void scanTranslatable(Context context) {
        final Bundle args = new Bundle();
        args.putBoolean(MediaStore.RETRANSLATE_CALL, true);
        context.startService(new Intent(context, MediaScannerService.class).putExtras(args));
    }
}

再對比下 Android 6.0 中 MediaScannerReceiver 源碼:

package com.android.providers.media;

import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.os.Environment;
import android.util.Log;

import java.io.File;
import java.io.IOException;

public class MediaScannerReceiver extends BroadcastReceiver {
    private final static String TAG = "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)) {
            // Scan both internal and external storage
            scan(context, MediaProvider.INTERNAL_VOLUME);
            scan(context, MediaProvider.EXTERNAL_VOLUME);

        } else {
            if (uri.getScheme().equals("file")) {
                // handle intents related to external storage
                String path = uri.getPath();
                String externalStoragePath = Environment.getExternalStorageDirectory().getPath();
                String legacyPath = Environment.getLegacyExternalStorageDirectory().getPath();

                try {
                    path = new File(path).getCanonicalPath();
                } catch (IOException e) {
                    Log.e(TAG, "couldn't canonicalize " + path);
                    return;
                }
                if (path.startsWith(legacyPath)) {
                    path = externalStoragePath + path.substring(legacyPath.length());
                }

                Log.d(TAG, "action: " + action + " path: " + path);
                if (Intent.ACTION_MEDIA_MOUNTED.equals(action)) {
                    // scan whenever any volume is mounted
                    scan(context, MediaProvider.EXTERNAL_VOLUME);
                } else if (Intent.ACTION_MEDIA_SCANNER_SCAN_FILE.equals(action) &&
                        path != null && path.startsWith(externalStoragePath + "/")) {
                    scanFile(context, path);
                }
            }
        }
    }

    private void scan(Context context, String volume) {
        Bundle args = new Bundle();
        args.putString("volume", volume);
        context.startService(new Intent(context, MediaScannerService.class).putExtras(args));
    }

    private void scanFile(Context context, String path) {
        Bundle args = new Bundle();
        args.putString("filepath", path);
        context.startService(new Intent(context, MediaScannerService.class).putExtras(args));
    }
}

掃描的時機(jī)為以下幾點(diǎn):

  1. Intent.ACTION_BOOT_COMPLETED.equals(action)
    6.0 中接到設(shè)備重啟的廣播毙死,對 Internal 和 External 掃描柜去,而 9.0 中只對 Internal 掃描。
  2. Intent.ACTION_LOCALE_CHANGED.equals(action)
    9.0 相比 6.0 增加了系統(tǒng)語言發(fā)生改變時的廣播郊酒,用于進(jìn)行掃描可以轉(zhuǎn)換語言的多媒體遇绞。
  3. uri.getScheme().equals("file")
    6.0 和 9.0 處理的一致,都是先過濾 scheme 為 "file" 的 Intent燎窘,再通過下面兩個 action 對 External 進(jìn)行掃描:
    • Intent.ACTION_MEDIA_MOUNTED.equals(action)
      插入外部存儲時掃描 scan()摹闽。
    • Intent.ACTION_MEDIA_SCANNER_SCAN_FILE.equals(action) && path != null && path.startsWith(externalStoragePath + "/")
      掃描外部存儲中的單個文件 scanFile()。

注意:不支持掃描外部存儲中的文件夾褐健,需要遍歷文件夾中文件钩骇,使用掃描單個文件的方式。


2. MediaScannerService 如何工作铝量?

MediaScannerService 繼承 Service倘屹,并實(shí)現(xiàn) Runnable 工作線程。通過 ServiceHandler 這個 Handler 把主線程需要大量計(jì)算的工作放到工作線程中慢叨。
/packages/providers/MediaProvider/src/com/android/providers/media/MediaScannerService.java

2.1 在 onCreate() 中啟動工作線程:

@Override
public void onCreate() {
    // 獲取電源鎖纽匙,防止掃描過程中休眠
    PowerManager pm = (PowerManager)getSystemService(Context.POWER_SERVICE);
    mWakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, TAG);
    // 獲取外部存儲掃描路徑
    StorageManager storageManager = (StorageManager)getSystemService(Context.STORAGE_SERVICE);
    mExternalStoragePaths = storageManager.getVolumePaths();

    // Start up the thread running the service.  Note that we create a
    // separate thread because the service normally runs in the process's
    // main thread, which we don't want to block.
    Thread thr = new Thread(null, this, "MediaScannerService"); // 啟動最重要的工作線程,該線程也是個消息泵線程
    thr.start();
}

可以看到拍谐,onCreate() 里會啟動最重要的工作線程烛缔,該線程也是個消息泵線程馏段。每當(dāng)用戶需要掃描媒體文件時,基本上都是在向這個消息泵里發(fā)送 Message践瓷,并在處理 Message 時完成真正的 scan 動作院喜。請注意,創(chuàng)建 Thread 時傳入的第二個參數(shù)就是 MediaScannerService 自身晕翠,也就是說線程的主要行為其實(shí)就是 MediaScannerService 的 run() 方法喷舀,該方法的代碼如下:

@Override
public void run() {
    // reduce priority below other background threads to avoid interfering
    // with other services at boot time.
    // 設(shè)置進(jìn)程優(yōu)先級,媒體掃描比較費(fèi)時淋肾,防止 CPU 一直被 MediaScannerService 占用
    // 這會導(dǎo)致用戶感覺系統(tǒng)變得很慢
    Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND +
            Process.THREAD_PRIORITY_LESS_FAVORABLE);
    Looper.prepare();

    mServiceLooper = Looper.myLooper(); // 消息looper
    mServiceHandler = new ServiceHandler(); // 發(fā)送消息的handler

    Looper.loop();
}

后續(xù)就是通過上面那個 mServiceHandler 向消息隊(duì)列發(fā)送 Message 的硫麻。

2.2 向工作線程發(fā)送 Message

比較常見的向消息泵發(fā)送 Message 的做法是調(diào)用 startService(),并在 MediaScannerService 的 onStartCommand() 方法里 sendMessage()樊卓。比如拿愧,和 MediaScannerService 配套提供的 MediaScannerReceiver,當(dāng)它收到類似 ACTION_BOOT_COMPLETED 這樣的系統(tǒng)廣播時碌尔,就會調(diào)用自己的 scan() 或 scanFile() 方法浇辜,里面的 startService() 動作會導(dǎo)致走到 service 的 onStartCommand(),并進(jìn)一步發(fā)送消息唾戚,其方法截選如下:

@Override
public int onStartCommand(Intent intent, int flags, int startId) {
    ...
    Message msg = mServiceHandler.obtainMessage();
    msg.arg1 = startId;
    msg.obj = intent.getExtras();
    mServiceHandler.sendMessage(msg); // 發(fā)送消息

    // Try again later if we are killed before we can finish scanning.
    return Service.START_REDELIVER_INTENT;
}

另外一種比較常見的發(fā)送 Message 的做法是先直接或間接 bindService()奢赂,綁定成功后會得到一個 IMediaScannerService 接口,而后外界再通過該接口向 MediaScannerService 發(fā)起命令颈走,請求其掃描特定文件或目錄膳灶。

IMediaScannerService 接口只提供了兩個接口方法:

  • void requestScanFile(String path, String mimeType, in IMediaScannerListener listener);
  • void scanFile(String path, String mimeType);

處理這兩種請求的實(shí)體是服務(wù)內(nèi)部的 mBinder 對象,參考代碼如下:

private final IMediaScannerService.Stub mBinder = new IMediaScannerService.Stub() {
    public void requestScanFile(String path, String mimeType, IMediaScannerListener listener) {
        if (false) {
            Log.d(TAG, "IMediaScannerService.scanFile: " + path + " mimeType: " + mimeType);
        }
        Bundle args = new Bundle();
        args.putString("filepath", path);
        args.putString("mimetype", mimeType);
        if (listener != null) {
            args.putIBinder("listener", listener.asBinder());
        }
        startService(new Intent(MediaScannerService.this,
                MediaScannerService.class).putExtras(args));
    }

    public void scanFile(String path, String mimeType) {
        requestScanFile(path, mimeType, null);
    }
};

說到底還是在調(diào)用 startService()立由。

具體處理消息泵線程里的消息時轧钓,執(zhí)行的是 ServiceHandler 的 handleMessage() 方法:

private final class ServiceHandler extends Handler {
    @Override
    public void handleMessage(Message msg) {
        Bundle arguments = (Bundle) msg.obj;
        String filePath = arguments.getString("filepath");

        try {
            if (filePath != null) {
                // 掃描單個文件
                ...
                try {
                    uri = scanFile(filePath, arguments.getString("mimetype"));
                } catch (Exception e) {
                    Log.e(TAG, "Exception scanning file", e);
                }
                ...
            } else if (arguments.getBoolean(MediaStore.RETRANSLATE_CALL)) {
                // 切換語言
                ContentProviderClient mediaProvider = getBaseContext().getContentResolver()
                    .acquireContentProviderClient(MediaStore.AUTHORITY);
                mediaProvider.call(MediaStore.RETRANSLATE_CALL, null, null);
            } else {
                // 掃描內(nèi)部或外部
                String volume = arguments.getString("volume");
                String[] directories = null;

                if (MediaProvider.INTERNAL_VOLUME.equals(volume)) {
                    // 如果是掃描內(nèi)部存儲,實(shí)際掃描目錄為
                    directories = new String[] {
                            Environment.getRootDirectory() + "/media",
                            Environment.getOemDirectory() + "/media",
                            Environment.getProductDirectory() + "/media",
                }
                else if (MediaProvider.EXTERNAL_VOLUME.equals(volume)) {
                    // 如果是掃描外部存儲锐膜,實(shí)際掃描目錄為
                    if (getSystemService(UserManager.class).isDemoUser()) {
                        directories = ArrayUtils.appendElement(String.class,
                                mExternalStoragePaths,
                                Environment.getDataPreloadsMediaDirectory().getAbsolutePath());
                    } else {
                        directories = mExternalStoragePaths;
                    }
                }
                // 調(diào)用 scan 函數(shù)開展文件夾掃描工作
                if (directories != null) {
                    scan(directories, volume);
                }
            }
        } catch (Exception e) {
            Log.e(TAG, "Exception in handleMessage", e);
        }

        stopSelf(msg.arg1); // 掃描結(jié)束毕箍,MediaScannerService完成本次使命,可以stop自身了
    }
}

MediaScannerService中 的 scanFile() 方法道盏,用來掃描單個文件:

private Uri scanFile(String path, String mimeType) {
    String volumeName = MediaProvider.EXTERNAL_VOLUME;

    try (MediaScanner scanner = new MediaScanner(this, volumeName)) {
        // make sure the file path is in canonical form
        String canonicalPath = new File(path).getCanonicalPath();
        return scanner.scanSingleFile(canonicalPath, mimeType);
    } catch (Exception e) {
        Log.e(TAG, "bad path " + path + " in scanFile()", e);
        return null;
    }
}

MediaScannerService 中的 scan() 方法而柑,用來掃描內(nèi)部或外部存儲的路徑:

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);
        // 通過 insert 這個特殊的 uri,讓 MeidaProvider 做一些準(zhǔn)備工作
        Uri scanUri = getContentResolver().insert(MediaStore.getMediaScannerUri(), values);
        // 發(fā)送開始掃描的廣播
        sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_STARTED, uri));

        try {
            if (volumeName.equals(MediaProvider.EXTERNAL_VOLUME)) {
                // 打開數(shù)據(jù)庫文件
                openDatabase(volumeName);
            }
            // 創(chuàng)建媒體掃描器荷逞,并調(diào)用 scanDirectories 掃描目標(biāo)文件夾
            try (MediaScanner scanner = new MediaScanner(this, volumeName)) {
                scanner.scanDirectories(directories);
            }
        } catch (Exception e) {
            Log.e(TAG, "exception in MediaScanner.scan()", e);
        }
        // 通過 delete 這個 uri媒咳,讓 MeidaProvider 做一些清理工作
        getContentResolver().delete(scanUri, null, null);

    } finally {
        // 發(fā)送結(jié)束掃描的廣播
        sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_FINISHED, uri));
        mWakeLock.release();
    }
}

上面的代碼中,比較復(fù)雜的是 MediaScannerService 和 MediaProvider 的交互种远。MediaScannerService 經(jīng)常使用一些特殊 Uri 做數(shù)據(jù)庫操作涩澡,而 MediaProvider 針對這些 Uri 會走一些特殊的處理,例如打開數(shù)據(jù)庫文件等坠敷,后面在 MediaProvider 中會重點(diǎn)說到妙同,我們還先回到掃描的邏輯射富。

scanFile() 或 scan() 才是實(shí)際進(jìn)行掃描的地方,掃描動作中主要借助的是 MediaScanner粥帚,它是打通 Java 層和 C++ 層的關(guān)鍵胰耗,掃描動作最終會調(diào)用到 MediaScanner的某個 native 函數(shù),于是程序流程開始走到 C++ 層芒涡。

現(xiàn)在柴灯,我們可以畫一張示意圖:


3. MediaScanner 如何工作?

顧名思義拖陆,MediaScanner 就是個“媒體文件掃描器”弛槐。它必須打通 Java 層和 C++ 層懊亡。請大家注意它的兩個 native 函數(shù):native_init() 和 native_setup()依啰,以及兩個重要成員變量:一個是mClient成員,另一個是 mNativeContext店枣,后面會詳細(xì)說明速警。
/frameworks/base/media/java/android/media/MediaScanner.java
MediaScanner的相關(guān)代碼截選如下:

public class MediaScanner implements AutoCloseable {
    static {
        System.loadLibrary("media_jni");
        native_init();    // 將java層和c++層聯(lián)系起來
    }
    ...
    private long mNativeContext;
    ...
    public MediaScanner(Context c, String volumeName) {
        native_setup();
        ...
    }
    ...
    // 一開始就具有明確的mClient對象
    private final MyMediaScannerClient mClient = new MyMediaScannerClient();
    ...
}

MediaScanner 類加載之時,就會同時加載動態(tài)鏈接庫“media_jni”鸯两,并調(diào)用 native_init() 將 Java 層和 C++ 層聯(lián)系起來闷旧。
/frameworks/base/media/jni/android_media_MediaScanner.cpp

// This function gets a field ID, which in turn causes class initialization.
// It is called from a static block in MediaScanner, which won't run until the
// first time an instance of this class is used.
static void
android_media_MediaScanner_native_init(JNIEnv *env)
{
    ALOGV("native_init");
    // Java 層 MediaScanner 類
    jclass clazz = env->FindClass(kClassMediaScanner);
    if (clazz == NULL) {
        return;
    }
    // Java 層 mNativeContext 對象(long 類型)保存在 JNI 層 fields.context 對象中
    fields.context = env->GetFieldID(clazz, "mNativeContext", "J");
    if (fields.context == NULL) {
        return;
    }
}

經(jīng)過分析代碼,我們發(fā)現(xiàn)在 C++ 層會有個與 MediaScanner 相對應(yīng)的類钧唐,叫作 StagefrightMediaScanner忙灼。當(dāng) Java層創(chuàng)建 MediaScanner 對象時,MediaScanner 的構(gòu)造函數(shù)就調(diào)用了 native_setup()钝侠,該函數(shù)對應(yīng)到 C++ 層就是 android_media_MediaScanner_native_setup()该园,其代碼如下:
/frameworks/base/media/jni/android_media_MediaScanner.cpp

static void
android_media_MediaScanner_native_setup(JNIEnv *env, jobject thiz)
{
    ALOGV("native_setup");
    // 創(chuàng)建 native 層的 MediaScanner 對象,StagefrightMediaScanner(frameworks/av/ 中定義)
    MediaScanner *mp = new StagefrightMediaScanner;

    if (mp == NULL) {
        jniThrowException(env, kRunTimeException, "Out of memory");
        return;
    }
    // 將 mp 指針保存在 Java 層 MediaScanner 類 mNativeContext 對象中
    env->SetLongField(thiz, fields.context, (jlong)mp);
}

最后一句 env->SetLongField() 其實(shí)就是在為 Java 層 MediaScanner 的 mNativeContext 域賦值帅韧。

后續(xù)我們會看到里初,每當(dāng) C++ 層執(zhí)行掃描動作時,還會再創(chuàng)建一個 MyMediaScannerClient 對象忽舟,這個對象和 Java 層的同名類對應(yīng)双妨。我們畫一張圖來說明:

image

3.1 scanSingleFile() 動作

// this function is used to scan a single file
public Uri scanSingleFile(String path, String mimeType) {
    try {
        prescan(path, true); // ① 掃描前預(yù)準(zhǔn)備

        File file = new File(path);
        if (!file.exists() || !file.canRead()) {
            return null;
        }

        // lastModified is in milliseconds on Files.
        long lastModifiedSeconds = file.lastModified() / 1000;

        // always scan the file, so we can return the content://media Uri for existing files
        // ② 掃描前預(yù)準(zhǔn)備
        return mClient.doScanFile(path, mimeType, lastModifiedSeconds, file.length(),
                false, true, MediaScanner.isNoMediaPath(path));
    } catch (RemoteException e) {
        Log.e(TAG, "RemoteException in MediaScanner.scanFile()", e);
        return null;
    } finally {
        releaseResources();
    }
}

先看①處代碼, prescan 函數(shù)比較關(guān)鍵叮阅,首先讓我們試想一個問題刁品。

在媒體掃描過程中,有個令人頭疼的問題浩姥,來舉個例子:假設(shè)某次掃描前 SD 卡中有 100 個媒體文件哑诊,數(shù)據(jù)庫中會有 100 條關(guān)于這些文件的記錄。現(xiàn)刪除其中的 50 個文件及刻,那么媒體數(shù)據(jù)庫什么時候會被更新呢镀裤?

MediaScanner 考慮到了這一點(diǎn)竞阐,prescan 函數(shù)的主要作用就是在掃描之前把上次掃描獲取的數(shù)據(jù)庫信息取出遍歷并檢測是否丟失,如果丟失暑劝,則從數(shù)據(jù)庫中刪除骆莹。

再看②處代碼,借助了 mClient.doScanFile()担猛,此處的 mClient 類型為 MyMediaScannerClient幕垦。

public Uri doScanFile(String path, String mimeType, long lastModified,
        long fileSize, boolean isDirectory, boolean scanAlways, boolean noMedia) {
    try {
        FileEntry entry = beginFile(path, mimeType, lastModified,
                fileSize, isDirectory, noMedia);
        ...

        // rescan for metadata if file was modified since last scan
        if (entry != null && (entry.mLastModifiedChanged || scanAlways)) {
            if (noMedia) {
                result = endFile(entry, false, false, false, false, false);
            } else {
                ...
                // we only extract metadata for audio and video files
                if (isaudio || isvideo) {
                    mScanSuccess = processFile(path, mimeType, this);
                }

                if (isimage) {
                    mScanSuccess = processImageFile(path);
                }
                ...
                result = endFile(entry, ringtones, notifications, alarms, music, podcasts);
            }
        }
    } catch (RemoteException e) {
        Log.e(TAG, "RemoteException in MediaScanner.scanFile()", e);
    }
    ...
    return result;
}

因?yàn)?MyMediaScannerClient 是 MediaScanner 的內(nèi)部類,所以它可以直接調(diào)用 MediaScanner 的 processFile()傅联。

現(xiàn)在我們畫一張 MediaScannerService.scanFile() 的調(diào)用關(guān)系圖:

scanFile() 的調(diào)用關(guān)系圖

3.2 scanDirectories() 動作

public void scanDirectories(String[] directories) {
    try {
        prescan(null, true);  // 掃描前預(yù)準(zhǔn)備
        ...
        for (int i = 0; i < directories.length; i++) {
            // native 函數(shù)先改,調(diào)用它來對目標(biāo)文件夾進(jìn)行掃描
            processDirectory(directories[i], mClient); 
        }
        ...
        postscan(directories);  // 掃描后處理
    } catch (SQLException e) {
        ...
    } finally {
        ...
    }
}

我們畫一張 MediaScannerService .scan() 的調(diào)用關(guān)系圖:

scan() 的調(diào)用關(guān)系圖

4. 調(diào)用到 C++ 層

這里就不深入展開,可以看下這篇文章《MediaScannerService研究》蒸走。


5. MediaProvider 如何工作

/packages/providers/MediaProvider/src/com/android/providers/media/MediaProvider.java

5.1 MediaProvider 何時創(chuàng)建數(shù)據(jù)庫

通常來說仇奶,數(shù)據(jù)庫的創(chuàng)建,應(yīng)該在 ContentProvider 的 onCreate() 方法中完成 比驻。

@Override
public boolean onCreate() {
    ...
    // DatabaseHelper緩存
    mDatabases = new HashMap<String, DatabaseHelper>();
    // 綁定內(nèi)部存儲數(shù)據(jù)庫
    attachVolume(INTERNAL_VOLUME);
    ...
    String state = Environment.getExternalStorageState();
    if (Environment.MEDIA_MOUNTED.equals(state) ||
            Environment.MEDIA_MOUNTED_READ_ONLY.equals(state)) {
         // 如果已掛載外部存儲该溯,綁定外部存儲數(shù)據(jù)庫
        attachVolume(EXTERNAL_VOLUME);
    }
    ...
    return true;
}

接下來分析attachVolume方法:

創(chuàng)建數(shù)據(jù)庫:

  • 如果此存儲卷已經(jīng)鏈接上了,則不執(zhí)行任何操作别惦。
  • 否則狈茉,查詢存儲卷的 ID 并且建立對應(yīng)的數(shù)據(jù)庫。
/**
 * 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
    // 更新路徑以反映當(dāng)前裝載的卷
    updateStoragePaths();

    DatabaseHelper helper = null;
    synchronized (mDatabases) {
        helper = mDatabases.get(volume);
        // 判斷是否已經(jīng)attached過了
        if (helper != null) {
            if (EXTERNAL_VOLUME.equals(volume)) {
                // 確保默認(rèn)的文件夾已經(jīng)被創(chuàng)建在掛載的主要存儲設(shè)備上掸掸,
                // 對每個存儲卷只做一次這種操作氯庆,所以當(dāng)用戶手動刪除時不會打擾
                ensureDefaultFolders(helper, helper.getWritableDatabase());
            }
            return Uri.parse("content://media/" + volume);
        }

        Context context = getContext();
        if (INTERNAL_VOLUME.equals(volume)) {
            // 如果是內(nèi)部存儲則直接實(shí)例化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();
                // 獲取主要的外部卷 ID
                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.
                // 數(shù)據(jù)庫都是以類似 external-ffffffff.db 的形式命名的扰付,
                // 后面的 8 個 16 進(jìn)制字符是該 SD 卡 FAT 分區(qū)的 Volume ID堤撵。
                // 該 ID 是分區(qū)時決定的,只有重新分區(qū)或者手動改變才會更改悯周,
                // 可以防止插入不同 SD 卡時數(shù)據(jù)庫沖突粒督。
                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.
                        // 已經(jīng)掛載但是sd卡是只讀狀態(tài)
                        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
                // 根據(jù)volume ID設(shè)置數(shù)據(jù)庫的名稱
                String dbName = "external-" + Integer.toHexString(volumeId) + ".db";
                // 創(chuàng)建外部存儲數(shù)據(jù)庫
                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
                // 外部數(shù)據(jù)庫名稱應(yīng)為EXTERNAL_DATABASE_NAME
                // 但是較早的版本對沒有可移動存儲的設(shè)備使用external-XXXXXXXX.db命名
                // 在這種情況下,我們需要轉(zhuǎn)換為新的約定
                ...
                // 根據(jù)之前轉(zhuǎn)換的數(shù)據(jù)庫名禽翼,創(chuàng)建數(shù)據(jù)庫
                helper = new DatabaseHelper(context, dbFile.getName(), false,
                        false, mObjectRemovedCallback);
            }
        } else {
            throw new IllegalArgumentException("There is no volume named " + volume);
        }
        // 緩存起來屠橄,標(biāo)識已經(jīng)創(chuàng)建過了數(shù)據(jù)庫
        mDatabases.put(volume, helper);
        ...
    }

    if (EXTERNAL_VOLUME.equals(volume)) {
        // 給外部存儲創(chuàng)建默認(rèn)的文件夾
        ensureDefaultFolders(helper, helper.getWritableDatabase());
    }
    return Uri.parse("content://media/" + volume);
}

先來關(guān)注一下 getPrimaryPhysicalVolume() 這個相關(guān)的方法:
/frameworks/base/core/java/android/os/storage/StorageManager.java

// 獲取主要的外部的 VolumeInfo
public @Nullable VolumeInfo getPrimaryPhysicalVolume() {
    final List<VolumeInfo> vols = getVolumes();
    for (VolumeInfo vol : vols) {
        if (vol.isPrimaryPhysical()) {
            return vol;
        }
    }
    return null;
}

/frameworks/base/core/java/android/os/storage/VolumeInfo.java

// 判斷該 VolumeInfo 是否是主要的,并且是外部的
public boolean isPrimaryPhysical() {
    return isPrimary() && (getType() == TYPE_PUBLIC);
}

// 判斷該 VolumeInfo 是否是主要的
public boolean isPrimary() {
    return (mountFlags & MOUNT_FLAG_PRIMARY) != 0;
}

下面就是分析創(chuàng)建數(shù)據(jù)庫的源頭DatabaseHelper:

/**
 * Creates database the first time we try to open it.
 */
@Override
public void onCreate(final SQLiteDatabase db) {
    // 在此方法中對700版本以下的都會新建數(shù)據(jù)庫
    updateDatabase(mContext, db, mInternal, 0, getDatabaseVersion(mContext));
}

/**
 * Updates the database format when a new content provider is used
 * with an older database format.
 */
@Override
public void onUpgrade(final SQLiteDatabase db, final int oldV, final int newV) {
    // 對數(shù)據(jù)庫進(jìn)行更新
    mUpgradeAttempted = true;
    updateDatabase(mContext, db, mInternal, oldV, newV);
}

這里強(qiáng)調(diào)一句 getDatabaseVersion() 方法獲取的 fromVersion 不是數(shù)據(jù)庫版本闰挡,而是 /packages/providers/MediaProvider/AndroidManifest.xml 中的 versionCode锐墙。

現(xiàn)在已經(jīng)找到創(chuàng)建數(shù)據(jù)庫的方法updateDatabase,現(xiàn)在大致分析一下此方法:

/**
 * This method takes care of updating all the tables in the database to the
 * current version, creating them if necessary.
 * This method can only update databases at schema 700 or higher, which was
 * used by the KitKat release. Older database will be cleared and recreated.
 * @param db Database
 * @param internal True if this is the internal media database
 */
private static void updateDatabase(Context context, SQLiteDatabase db, boolean internal,
        int fromVersion, int toVersion) {
    ...
    // 對不同版本的數(shù)據(jù)庫進(jìn)行判斷
    if (fromVersion < 700) {
        // 小于700长酗,重新創(chuàng)建數(shù)據(jù)庫
        createLatestSchema(db, internal);
    } else if (fromVersion < 800) {
        // 對700-800之間的數(shù)據(jù)庫處理
        updateFromKKSchema(db);
    } else if (fromVersion < 900) {
        // 對800-900之間的數(shù)據(jù)庫處理
        updateFromOCSchema(db);
    }
    // 檢查audio_meta的_data值是否是不同的溪北,如果不同就刪除audio_meta,
    // 在掃描的時候重新創(chuàng)建
    sanityCheck(db, fromVersion);
}

那么還有一個疑惑,我們知道 ContentProvider 的 onCreate() 執(zhí)行時間之拨,早于 Application onCreate()茉继,那么在 onCreate() 之后掛載外部存儲,是如何處理的呢蚀乔?

搜索 attachVolume() 的調(diào)用位置烁竭,可以找到在 insertInternal() 中看到:

private Uri insertInternal(Uri uri, int match, ContentValues initialValues,
                           ArrayList<Long> notifyRowIds) {
    ...
    switch (match) {
        ...
        case VOLUMES:
        {
            String name = initialValues.getAsString("name");
            // 根據(jù)name綁定存儲數(shù)據(jù)庫
            Uri attachedVolume = attachVolume(name);
            ...
            return attachedVolume;
        }
    ...
}

根據(jù) VOLUMES 找到對應(yīng)的 URI:

URI_MATCHER.addURI("media", null, VOLUMES);

而調(diào)用 insertInternal() 方法的地方,是在 insert() 方法中吉挣。

那么說明派撕,必然存在一個調(diào)用 insert() 方法,并傳入了 "content://media/" 的URI睬魂,可以在 MediaScannerService 的 openDatabase() 方法中找到:
/packages/providers/MediaProvider/src/com/android/providers/media/MediaScannerService.java

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");
    }         
}

調(diào)用 openDatabase() 方法的地方就是在開始掃描外部存儲的時候终吼,也就在這個時候,進(jìn)行了 DatabaseHelper 的實(shí)例化氯哮,在前文已經(jīng)分析了 scan() 的代碼际跪,為了方便查看,這里再列該方法:

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();
    }
}

至此蛙粘,對于數(shù)據(jù)庫的創(chuàng)建已經(jīng)分析完畢垫卤。

5.2 MediaProvider 更新

@Override
public int (Uri uri, ContentValues initialValues, String userWhere,
        String[] whereArgs) {
    // 將uri進(jìn)行轉(zhuǎn)換成合適的格式威彰,去除標(biāo)準(zhǔn)化
    uri = safeUncanonicalize(uri);
    int count;
    // 對uri進(jìn)行匹配
    int match = URI_MATCHER.match(uri);
    // 返回查詢的對應(yīng)uri的數(shù)據(jù)庫幫助類
    DatabaseHelper helper = getDatabaseForUri(uri);
    // 記錄更新的次數(shù)
    helper.mNumUpdates++;
    // 通過可寫的方式獲得數(shù)據(jù)庫實(shí)例
    SQLiteDatabase db = helper.getWritableDatabase();
    String genre = null;
    if (initialValues != null) {
        // 獲取流派的信息出牧,然后刪除掉
        genre = initialValues.getAsString(Audio.AudioColumns.GENRE);
        initialValues.remove(Audio.AudioColumns.GENRE);
    }
    ...
    // 根據(jù)匹配的uri進(jìn)行相應(yīng)的操作
    switch (match) {
        case AUDIO_MEDIA:
        case AUDIO_MEDIA_ID:
        // 更新音樂人和專輯字段。首先從緩存中判斷是否有值歇盼,如果有直接用緩存中的
        // 數(shù)據(jù)舔痕,如果沒有再從數(shù)據(jù)庫中查詢是否有對應(yīng)的信息,如果有則更新豹缀,
        // 如果沒有插入這條數(shù)據(jù).接下來的操作是增加更新次數(shù)伯复,并更新流派
        ...
        case IMAGES_MEDIA:
        case IMAGES_MEDIA_ID:
        case VIDEO_MEDIA:
        case VIDEO_MEDIA_ID:
        // 更新視頻,并且發(fā)出生成略縮圖請求
        ...
        case AUDIO_PLAYLISTS_ID_MEMBERS_ID:
        // 更新播放列表數(shù)據(jù)
        ...
    }
    ...
}

至此邢笙,更新操作已完成啸如。

5.3 MediaProvider 插入

關(guān)于插入,有兩個方法插入氮惯,一個是大量的插入 bulkInsert 方法傳入的是 ContentValues 數(shù)組叮雳;一個是 insert,傳入的是單一個 ContentValues妇汗。下面分別分析:

@Override
public int bulkInsert(Uri uri, ContentValues values[]) {
    // 首先對傳入的Uri進(jìn)行匹配
    int match = URI_MATCHER.match(uri);
    if (match == VOLUMES) {
        // 如果是匹配的是存儲卷帘不,則直接調(diào)用父類的方法,進(jìn)行循環(huán)插入
        return super.bulkInsert(uri, values);
    }
    // 對DatabaseHelper和SQLiteDatabase的初始化
    DatabaseHelper helper = getDatabaseForUri(uri);
    if (helper == null) {
        throw new UnsupportedOperationException(
                "Unknown URI: " + uri);
    }
    SQLiteDatabase db = helper.getWritableDatabase();
    if (db == null) {
        throw new IllegalStateException("Couldn't open database for " + uri);
    }

    if (match == AUDIO_PLAYLISTS_ID || match == AUDIO_PLAYLISTS_ID_MEMBERS) {
        // 插入播放列表的數(shù)據(jù)杨箭,在playlistBulkInsert中是開啟的事務(wù)進(jìn)行插入
        return playlistBulkInsert(db, uri, values);
    } else if (match == MTP_OBJECT_REFERENCES) {
        // 將MTP對象的ID轉(zhuǎn)換成音頻的ID寞焙,最終也是調(diào)用到playlistBulkInsert
        int handle = Integer.parseInt(uri.getPathSegments().get(2));
        return setObjectReferences(helper, db, handle, values);
    }

    ArrayList<Long> notifyRowIds = new ArrayList<Long>();
    int numInserted = 0;
    // insert may need to call getParent(), which in turn may need to update the database,
    // so synchronize on mDirectoryCache to avoid deadlocks
    synchronized (mDirectoryCache) {
         // 如果不滿足上述的條件,則開啟事務(wù)進(jìn)行插入其他的數(shù)據(jù)
        db.beginTransaction();
        try {
            int len = values.length;
            for (int i = 0; i < len; i++) {
                if (values[i] != null) {
                    // 循環(huán)調(diào)用insertInternal去插入相關(guān)的數(shù)據(jù)
                    insertInternal(uri, match, values[i], notifyRowIds);
                }
            }
            numInserted = len;
            db.setTransactionSuccessful();
        } finally {
            // 結(jié)束事務(wù)
            db.endTransaction();
        }
    }

    // 通知更新
    getContext().getContentResolver().notifyChange(uri, null);
    return numInserted;
}

@Override
public Uri insert(Uri uri, ContentValues initialValues) {
    int match = URI_MATCHER.match(uri);

    ArrayList<Long> notifyRowIds = new ArrayList<Long>();
    // 只是調(diào)用insertInternal進(jìn)行插入
    Uri newUri = insertInternal(uri, match, initialValues, notifyRowIds);

    // do not signal notification for MTP objects.
    // we will signal instead after file transfer is successful.
    if (newUri != null && match != MTP_OBJECTS) {
        // Report a general change to the media provider.
        // We only report this to observers that are not looking at
        // this specific URI and its descendants, because they will
        // still see the following more-specific URI and thus get
        // redundant info (and not be able to know if there was just
        // the specific URI change or also some general change in the
        // parent URI).
        getContext().getContentResolver().notifyChange(uri, null, match != MEDIA_SCANNER
                ? ContentResolver.NOTIFY_SKIP_NOTIFY_FOR_DESCENDANTS : 0);
        // Also report the specific URIs that changed.
        if (match != MEDIA_SCANNER) {
            getContext().getContentResolver().notifyChange(newUri, null, 0);
        }
    }
    return newUri;
}

5.4 MediaProvider 刪除

@Override
public int delete(Uri uri, String userWhere, String[] whereArgs) {
    uri = safeUncanonicalize(uri);
    int count;
    int match = URI_MATCHER.match(uri);

    // handle MEDIA_SCANNER before calling getDatabaseForUri()
    if (match == MEDIA_SCANNER) {
        if (mMediaScannerVolume == null) {
            return 0;
        }
        DatabaseHelper database = getDatabaseForUri(
                Uri.parse("content://media/" + mMediaScannerVolume + "/audio"));
        if (database == null) {
            Log.w(TAG, "no database for scanned volume " + mMediaScannerVolume);
        } else {
            database.mScanStopTime = SystemClock.currentTimeMicro();
            String msg = dump(database, false);
            logToDb(database.getWritableDatabase(), msg);
        }
        if (INTERNAL_VOLUME.equals(mMediaScannerVolume)) {
            // persist current build fingerprint as fingerprint for system (internal) sound scan
            final SharedPreferences scanSettings =
                    getContext().getSharedPreferences(MediaScanner.SCANNED_BUILD_PREFS_NAME,
                            Context.MODE_PRIVATE);
            final SharedPreferences.Editor editor = scanSettings.edit();
            editor.putString(MediaScanner.LAST_INTERNAL_SCAN_FINGERPRINT, Build.FINGERPRINT);
            editor.apply();
        }
        mMediaScannerVolume = null;
        pruneThumbnails();
        return 1;
    }

    if (match == VOLUMES_ID) {
        detachVolume(uri);
        count = 1;
    } else if (match == MTP_CONNECTED) {
        synchronized (mMtpServiceConnection) {
            if (mMtpService != null) {
                // MTP has disconnected, so release our connection to MtpService
                getContext().unbindService(mMtpServiceConnection);
                count = 1;
                // mMtpServiceConnection.onServiceDisconnected might not get called,
                // so set mMtpService = null here
                mMtpService = null;
            } else {
                count = 0;
            }
        }
    } else {
        final String volumeName = getVolumeName(uri);
        final boolean isExternal = "external".equals(volumeName);

        DatabaseHelper database = getDatabaseForUri(uri);
        if (database == null) {
            throw new UnsupportedOperationException(
                    "Unknown URI: " + uri + " match: " + match);
        }
        database.mNumDeletes++;
        SQLiteDatabase db = database.getWritableDatabase();

        TableAndWhere tableAndWhere = getTableAndWhere(uri, match, userWhere);
        if (tableAndWhere.table.equals("files")) {
            String deleteparam = uri.getQueryParameter(MediaStore.PARAM_DELETE_DATA);
            if (deleteparam == null || ! deleteparam.equals("false")) {
                database.mNumQueries++;
                Cursor c = db.query(tableAndWhere.table,
                        sMediaTypeDataId,
                        tableAndWhere.where, whereArgs,
                        null /* groupBy */, null /* having */, null /* orderBy */);
                String [] idvalue = new String[] { "" };
                String [] playlistvalues = new String[] { "", "" };
                MiniThumbFile imageMicroThumbs = null;
                MiniThumbFile videoMicroThumbs = null;
                try {
                    while (c.moveToNext()) {
                        final int mediaType = c.getInt(0);
                        final String data = c.getString(1);
                        final long id = c.getLong(2);

                        if (mediaType == FileColumns.MEDIA_TYPE_IMAGE) {
                            deleteIfAllowed(uri, data);
                            MediaDocumentsProvider.onMediaStoreDelete(getContext(),
                                    volumeName, FileColumns.MEDIA_TYPE_IMAGE, id);

                            idvalue[0] = String.valueOf(id);
                            database.mNumQueries++;
                            Cursor cc = db.query("thumbnails", sDataOnlyColumn,
                                        "image_id=?", idvalue,
                                        null /* groupBy */, null /* having */,
                                        null /* orderBy */);
                            try {
                                while (cc.moveToNext()) {
                                    deleteIfAllowed(uri, cc.getString(0));
                                }
                                database.mNumDeletes++;
                                db.delete("thumbnails", "image_id=?", idvalue);
                            } finally {
                                IoUtils.closeQuietly(cc);
                            }
                            if (isExternal) {
                                if (imageMicroThumbs == null) {
                                    imageMicroThumbs = MiniThumbFile.instance(
                                            Images.Media.EXTERNAL_CONTENT_URI);
                                }
                                imageMicroThumbs.eraseMiniThumb(id);
                            }
                        } else if (mediaType == FileColumns.MEDIA_TYPE_VIDEO) {
                            deleteIfAllowed(uri, data);
                            MediaDocumentsProvider.onMediaStoreDelete(getContext(),
                                    volumeName, FileColumns.MEDIA_TYPE_VIDEO, id);

                            idvalue[0] = String.valueOf(id);
                            database.mNumQueries++;
                            Cursor cc = db.query("videothumbnails", sDataOnlyColumn,
                                        "video_id=?", idvalue, null, null, null);
                            try {
                                while (cc.moveToNext()) {
                                    deleteIfAllowed(uri, cc.getString(0));
                                }
                                database.mNumDeletes++;
                                db.delete("videothumbnails", "video_id=?", idvalue);
                            } finally {
                                IoUtils.closeQuietly(cc);
                            }
                            if (isExternal) {
                                if (videoMicroThumbs == null) {
                                    videoMicroThumbs = MiniThumbFile.instance(
                                            Video.Media.EXTERNAL_CONTENT_URI);
                                }
                                videoMicroThumbs.eraseMiniThumb(id);
                            }
                        } else if (mediaType == FileColumns.MEDIA_TYPE_AUDIO) {
                            if (!database.mInternal) {
                                MediaDocumentsProvider.onMediaStoreDelete(getContext(),
                                        volumeName, FileColumns.MEDIA_TYPE_AUDIO, id);

                                idvalue[0] = String.valueOf(id);
                                database.mNumDeletes += 2; // also count the one below
                                db.delete("audio_genres_map", "audio_id=?", idvalue);
                                // for each playlist that the item appears in, move
                                // all the items behind it forward by one
                                Cursor cc = db.query("audio_playlists_map",
                                            sPlaylistIdPlayOrder,
                                            "audio_id=?", idvalue, null, null, null);
                                try {
                                    while (cc.moveToNext()) {
                                        playlistvalues[0] = "" + cc.getLong(0);
                                        playlistvalues[1] = "" + cc.getInt(1);
                                        database.mNumUpdates++;
                                        db.execSQL("UPDATE audio_playlists_map" +
                                                " SET play_order=play_order-1" +
                                                " WHERE playlist_id=? AND play_order>?",
                                                playlistvalues);
                                    }
                                    db.delete("audio_playlists_map", "audio_id=?", idvalue);
                                } finally {
                                    IoUtils.closeQuietly(cc);
                                }
                            }
                        } else if (mediaType == FileColumns.MEDIA_TYPE_PLAYLIST) {
                            // TODO, maybe: remove the audio_playlists_cleanup trigger and
                            // implement functionality here (clean up the playlist map)
                        }
                    }
                } finally {
                    IoUtils.closeQuietly(c);
                    if (imageMicroThumbs != null) {
                        imageMicroThumbs.deactivate();
                    }
                    if (videoMicroThumbs != null) {
                        videoMicroThumbs.deactivate();
                    }
                }
                // Do not allow deletion if the file/object is referenced as parent
                // by some other entries. It could cause database corruption.
                if (!TextUtils.isEmpty(tableAndWhere.where)) {
                    tableAndWhere.where =
                            "(" + tableAndWhere.where + ")" +
                                    " AND (_id NOT IN (SELECT parent FROM files" +
                                    " WHERE NOT (" + tableAndWhere.where + ")))";
                } else {
                    tableAndWhere.where = ID_NOT_PARENT_CLAUSE;
                }
            }
        }

        switch (match) {
            case MTP_OBJECTS:
            case MTP_OBJECTS_ID:
                database.mNumDeletes++;
                count = db.delete("files", tableAndWhere.where, whereArgs);
                break;
            case AUDIO_GENRES_ID_MEMBERS:
                database.mNumDeletes++;
                count = db.delete("audio_genres_map",
                        tableAndWhere.where, whereArgs);
                break;

            case IMAGES_THUMBNAILS_ID:
            case IMAGES_THUMBNAILS:
            case VIDEO_THUMBNAILS_ID:
            case VIDEO_THUMBNAILS:
                // Delete the referenced files first.
                Cursor c = db.query(tableAndWhere.table,
                        sDataOnlyColumn,
                        tableAndWhere.where, whereArgs, null, null, null);
                if (c != null) {
                    try {
                        while (c.moveToNext()) {
                            deleteIfAllowed(uri, c.getString(0));
                        }
                    } finally {
                        IoUtils.closeQuietly(c);
                    }
                }
                database.mNumDeletes++;
                count = db.delete(tableAndWhere.table,
                        tableAndWhere.where, whereArgs);
                break;

            default:
                database.mNumDeletes++;
                count = db.delete(tableAndWhere.table,
                        tableAndWhere.where, whereArgs);
                break;
        }

        // Since there are multiple Uris that can refer to the same files
        // and deletes can affect other objects in storage (like subdirectories
        // or playlists) we will notify a change on the entire volume to make
        // sure no listeners miss the notification.
        Uri notifyUri = Uri.parse("content://" + MediaStore.AUTHORITY + "/" + volumeName);
        getContext().getContentResolver().notifyChange(notifyUri, null);
    }

    return count;
}

5.5 MediaProvider 查詢

public Cursor query(Uri uri, String[] projectionIn, String selection,
        String[] selectionArgs, String sort) {
    uri = safeUncanonicalize(uri);
    int table = URI_MATCHER.match(uri);
    List<String> prependArgs = new ArrayList<String>();
    // handle MEDIA_SCANNER before calling getDatabaseForUri()
    if (table == MEDIA_SCANNER) {
        if (mMediaScannerVolume == null) {
            return null;
        } else {
            // create a cursor to return volume currently being scanned by the media scanner
            MatrixCursor c = new MatrixCursor(
                new String[] {MediaStore.MEDIA_SCANNER_VOLUME});
            c.addRow(new String[] {mMediaScannerVolume});
            //直接返回的是有關(guān)存儲卷的cursor
            return c;
        }
    }
    // 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;
    }
    if (table == VERSION) {
        MatrixCursor c = new MatrixCursor(new String[] {"version"});
        c.addRow(new Integer[] {getDatabaseVersion(getContext())});
        return c;
    }
    //初始化DatabaseHelper和SQLiteDatabase
    String groupBy = null;
    DatabaseHelper helper = getDatabaseForUri(uri);
    if (helper == null) {
        return null;
    }
    helper.mNumQueries++;
    SQLiteDatabase db = null;
    try {
        db = helper.getReadableDatabase();
    } catch (Exception e) {
        e.printStackTrace();
        return null;
    }
    if (db == null) return null;
    // SQLiteQueryBuilder類是組成查詢語句的幫助類
    SQLiteQueryBuilder qb = new SQLiteQueryBuilder();
    //獲取uri里面的查詢字符
    String limit = uri.getQueryParameter("limit");
    String filter = uri.getQueryParameter("filter");
    String [] keywords = null;
    if (filter != null) {
        filter = Uri.decode(filter).trim();
        if (!TextUtils.isEmpty(filter)) {
            //對字符進(jìn)行篩選
            String [] searchWords = filter.split(" ");
            keywords = new String[searchWords.length];
            for (int i = 0; i < searchWords.length; i++) {
                String key = MediaStore.Audio.keyFor(searchWords[i]);
                key = key.replace("\\", "\\\\");
                key = key.replace("%", "\\%");
                key = key.replace("_", "\\_");
                keywords[i] = key;
            }
        }
    }
    if (uri.getQueryParameter("distinct") != null) {
        qb.setDistinct(true);
    }
    boolean hasThumbnailId = false;
    //對匹配的其他類型進(jìn)行設(shè)置查詢語句的操作
    switch (table) {
        case IMAGES_MEDIA:
                //設(shè)置查詢的表是images
                qb.setTables("images");
                if (uri.getQueryParameter("distinct") != null)
                    //設(shè)置為唯一的
                    qb.setDistinct(true);
                break;
         //其他類型相類似
         ... ...
    }
    //根據(jù)拼裝的搜索條件,進(jìn)行查詢
    Cursor c = qb.query(db, projectionIn, selection,
             combine(prependArgs, selectionArgs), groupBy, null, sort, limit);

    if (c != null) {
        String nonotify = uri.getQueryParameter("nonotify");
        if (nonotify == null || !nonotify.equals("1")) {
            //通知更新數(shù)據(jù)庫
            c.setNotificationUri(getContext().getContentResolver(), uri);
        }
    }
    return c;
}

6. MediaProvider 如何更新數(shù)據(jù)庫

站在 Java 層來看捣郊,不管是掃描具體的文件辽狈,還是掃描一個目錄,最終都會走到 Java 層 MyMediaScannerClient 的 doScanFile()呛牲。在前文我們已經(jīng)列出過這個函數(shù)的代碼稻艰,為了說明問題,這里再列一下其中的重要句子:
/frameworks/base/media/java/android/media/MediaScanner.java

public Uri doScanFile(String path, String mimeType, long lastModified,
        long fileSize, boolean isDirectory, boolean scanAlways, boolean noMedia) {
    try {
        // ① beginFile
        FileEntry entry = beginFile(path, mimeType, lastModified,
                fileSize, isDirectory, noMedia);
        ...

        // rescan for metadata if file was modified since last scan
        if (entry != null && (entry.mLastModifiedChanged || scanAlways)) {
            if (noMedia) {
                result = endFile(entry, false, false, false, false, false);
            } else {
                // 正常文件處理走到這里
                ...
                // we only extract metadata for audio and video files
                if (isaudio || isvideo) {
                    // ② processFile 這邊主要是解析媒體文件的元數(shù)據(jù)侈净,以便后續(xù)存入到數(shù)據(jù)庫中
                    mScanSuccess = processFile(path, mimeType, this);
                }

                if (isimage) {
                    mScanSuccess = processImageFile(path);
                }
                ...
                // ③ endFile
                result = endFile(entry, ringtones, notifications, alarms, music, podcasts);
            }
        }
    } catch (RemoteException e) {
        Log.e(TAG, "RemoteException in MediaScanner.scanFile()", e);
    }
    ...
    return result;
}

重看一下其中和 MediaProvider 相關(guān)的 beginFile() 和 endFile()尊勿。

beginFile()是為了后續(xù)和MediaProvider打交道,準(zhǔn)備一個FileEntry畜侦。FileEntry的定義如下:

private static class FileEntry {
    long mRowId;
    String mPath;
    long mLastModified;
    int mFormat;
    boolean mLastModifiedChanged;

    FileEntry(long rowId, String path, long lastModified, int format) {
        mRowId = rowId;
        mPath = path;
        mLastModified = lastModified;
        mFormat = format;
        mLastModifiedChanged = false;
    }
    ...
}

FileEntry 的幾個成員變量元扔,其實(shí)體現(xiàn)了查表時的若干列的值。
beginFile()的代碼截選如下:

public FileEntry beginFile(String path, String mimeType, long lastModified,
        long fileSize, boolean isDirectory, boolean noMedia) {
    ...
    FileEntry entry = makeEntryFor(path); // 從MediaProvider中查出該文件或目錄對應(yīng)的入口
    ...
    if (entry == null || wasModified) {
        // 不管原來表中是否存在這個路徑文件數(shù)據(jù)旋膳,這里面都會執(zhí)行到
        if (wasModified) {
            // 更新最后編輯時間
            entry.mLastModified = lastModified;
        } else {
            // 如果前面沒查到FileEntry澎语,就在這里new一個新的FileEntry
            entry = new FileEntry(0, path, lastModified,
                    (isDirectory ? MtpConstants.FORMAT_ASSOCIATION : 0));
        }
        entry.mLastModifiedChanged = true;
    }
    ...
    return entry;
}

其中調(diào)用的 makeEntryFor() 內(nèi)部就會查詢 MediaProvider:

FileEntry makeEntryFor(String path) {
    String where;
    String[] selectionArgs;

    Cursor c = null;
    try {
        where = Files.FileColumns.DATA + "=?";
        selectionArgs = new String[] { path };
        c = mMediaProvider.query(mFilesUriNoNotify, FILES_PRESCAN_PROJECTION,
                where, selectionArgs, null, null);
        if (c.moveToFirst()) {
            long rowId = c.getLong(FILES_PRESCAN_ID_COLUMN_INDEX);
            int format = c.getInt(FILES_PRESCAN_FORMAT_COLUMN_INDEX);
            long lastModified = c.getLong(FILES_PRESCAN_DATE_MODIFIED_COLUMN_INDEX);
            return new FileEntry(rowId, path, lastModified, format);
        }
    } catch (RemoteException e) {
    } finally {
        if (c != null) {
            c.close();
        }
    }
    return null;
}

查詢語句中用的 FILES_PRESCAN_PROJECTION 的定義如下:

private static final String[] FILES_PRESCAN_PROJECTION = new String[] {
        Files.FileColumns._ID, // 0
        Files.FileColumns.DATA, // 1
        Files.FileColumns.FORMAT, // 2
        Files.FileColumns.DATE_MODIFIED, // 3
};

看到了嗎,特意要去查一下 MediaProvider 中記錄的待查文件的最后修改日期验懊。能查到就返回一個 FileEntry擅羞,如果查詢時出現(xiàn)異常就返回 null。beginFile() 的 lastModified 參數(shù)可以理解為是從文件系統(tǒng)里拿到的待查文件的最后修改日期义图,它應(yīng)該是最準(zhǔn)確的减俏。而 MediaProvider 里記錄的信息則有可能“較老”。beginFile() 內(nèi)部通過比對這兩個“最后修改日期”碱工,就可以知道該文件是不是真的改動了娃承。如果的確改動了,就要把 FileEntry 里的 mLastModified 調(diào)整成最新數(shù)據(jù)怕篷。

基本上而言历筝,beginFile() 會返回一個 FileEntry。如果該階段沒能在MediaProvider里找到文件對應(yīng)的記錄廊谓,那么 FileEntry 對象的mRowId會為0梳猪,而如果找到了,則為非0值蒸痹。

與 beginFile() 相對的春弥,就是 endFile() 了。endFile() 是真正向 MediaProvider 數(shù)據(jù)庫插入數(shù)據(jù)或更新數(shù)據(jù)的地方电抚。當(dāng) FileEntry 的 mRowId 為0時惕稻,會考慮調(diào)用:

result = mMediaProvider.insert(tableUri, values);

而當(dāng) mRowId 為非0值時,則會考慮調(diào)用:

mMediaProvider.update(result, values, null, null);

這就是改變 MediaProvider 中相關(guān)信息的最核心代碼蝙叛。

endFile() 的代碼截選如下:

private Uri endFile(FileEntry entry, boolean ringtones, boolean notifications,
        boolean alarms, boolean music, boolean podcasts)
        throws RemoteException {
    ...
    ContentValues values = toValues();
    String title = values.getAsString(MediaStore.MediaColumns.TITLE);
    if (title == null || TextUtils.isEmpty(title.trim())) {
        title = MediaFile.getFileTitle(values.getAsString(MediaStore.MediaColumns.DATA));
        values.put(MediaStore.MediaColumns.TITLE, title);
    }
    ...
    long rowId = entry.mRowId;
    if (MediaFile.isAudioFileType(mFileType) && (rowId == 0 || mMtpObjectHandle != 0)) {
        // Only set these for new entries. For existing entries, they
        // may have been modified later, and we want to keep the current
        // values so that custom ringtones still show up in the ringtone
        // picker.
        values.put(Audio.Media.IS_RINGTONE, ringtones);
        values.put(Audio.Media.IS_NOTIFICATION, notifications);
        values.put(Audio.Media.IS_ALARM, alarms);
        values.put(Audio.Media.IS_MUSIC, music);
        values.put(Audio.Media.IS_PODCAST, podcasts);
    } else if ((mFileType == MediaFile.FILE_TYPE_JPEG
            || mFileType == MediaFile.FILE_TYPE_HEIF
            || MediaFile.isRawImageFileType(mFileType)) && !mNoMedia) {
        ...
    }
    ...
    if (rowId == 0) {
        // 掃描的是新文件俺祠,insert記錄。如果是目錄的話,必須比它所含有的所有文件更早插入記錄蜘渣,
        // 所以在批量插入時淌铐,就需要有更高的優(yōu)先權(quán)。如果是文件的話蔫缸,而且我們現(xiàn)在就需要其對應(yīng)
        // 的rowId腿准,那么應(yīng)該立即進(jìn)行插入,此時不過多考慮批量插入拾碌。
        // New file, insert it.
        // Directories need to be inserted before the files they contain, so they
        // get priority when bulk inserting.
        // If the rowId of the inserted file is needed, it gets inserted immediately,
        // bypassing the bulk inserter.
        if (inserter == null || needToSetSettings) {
            if (inserter != null) {
                inserter.flushAll();
            }
            result = mMediaProvider.insert(tableUri, values);
        } else if (entry.mFormat == MtpConstants.FORMAT_ASSOCIATION) {
            inserter.insertwithPriority(tableUri, values);
        } else {
            inserter.insert(tableUri, values);
        }

        if (result != null) {
            rowId = ContentUris.parseId(result);
            entry.mRowId = rowId;
        }
    } else {
        ...
        mMediaProvider.update(result, values, null, null);
    }
    ...
    return result;
}

除了直接調(diào)用 mMediaProvider.insert() 向 MediaProvider 中寫入數(shù)據(jù)吐葱,函數(shù)中還有一種方式是經(jīng)由 inserter 對象,其類型為 MediaInserter校翔。

MediaInserter 也是向 MediaProvider 中寫入數(shù)據(jù)弟跑,最終大體上會走到其 flush() 函數(shù),該函數(shù)的代碼如下:

private void flush(Uri tableUri, List<ContentValues> list) throws RemoteException {
    if (!list.isEmpty()) {
        ContentValues[] valuesArray = new ContentValues[list.size()];
        valuesArray = list.toArray(valuesArray);
        mProvider.bulkInsert(tableUri, valuesArray);
        list.clear();
    }
}

參考

Android-MediaScanner&MediaProvider學(xué)習(xí)
Android MediaScanner
Android掃描多媒體文件剖析
MediaScannerService研究
android_9.0 MediaScanner 媒體掃描詳解
Android 多媒體掃描 MediaScannerConnection
Android多媒體總綱
MediaProvider流程分析
多媒體文件管理-數(shù)據(jù)庫external.db防症,internal.db (一)

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末孟辑,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子蔫敲,更是在濱河造成了極大的恐慌饲嗽,老刑警劉巖,帶你破解...
    沈念sama閱讀 212,599評論 6 492
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件奈嘿,死亡現(xiàn)場離奇詭異貌虾,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)指么,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 90,629評論 3 385
  • 文/潘曉璐 我一進(jìn)店門酝惧,熙熙樓的掌柜王于貴愁眉苦臉地迎上來榴鼎,“玉大人伯诬,你說我怎么就攤上這事∥撞疲” “怎么了盗似?”我有些...
    開封第一講書人閱讀 158,084評論 0 348
  • 文/不壞的土叔 我叫張陵,是天一觀的道長平项。 經(jīng)常有香客問我赫舒,道長,這世上最難降的妖魔是什么闽瓢? 我笑而不...
    開封第一講書人閱讀 56,708評論 1 284
  • 正文 為了忘掉前任接癌,我火速辦了婚禮,結(jié)果婚禮上扣讼,老公的妹妹穿的比我還像新娘缺猛。我一直安慰自己,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,813評論 6 386
  • 文/花漫 我一把揭開白布荔燎。 她就那樣靜靜地躺著耻姥,像睡著了一般。 火紅的嫁衣襯著肌膚如雪有咨。 梳的紋絲不亂的頭發(fā)上琐簇,一...
    開封第一講書人閱讀 50,021評論 1 291
  • 那天,我揣著相機(jī)與錄音座享,去河邊找鬼婉商。 笑死,一個胖子當(dāng)著我的面吹牛渣叛,可吹牛的內(nèi)容都是我干的据某。 我是一名探鬼主播,決...
    沈念sama閱讀 39,120評論 3 410
  • 文/蒼蘭香墨 我猛地睜開眼诗箍,長吁一口氣:“原來是場噩夢啊……” “哼癣籽!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起滤祖,我...
    開封第一講書人閱讀 37,866評論 0 268
  • 序言:老撾萬榮一對情侶失蹤筷狼,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后匠童,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體埂材,經(jīng)...
    沈念sama閱讀 44,308評論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,633評論 2 327
  • 正文 我和宋清朗相戀三年汤求,在試婚紗的時候發(fā)現(xiàn)自己被綠了俏险。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,768評論 1 341
  • 序言:一個原本活蹦亂跳的男人離奇死亡扬绪,死狀恐怖竖独,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情挤牛,我是刑警寧澤莹痢,帶...
    沈念sama閱讀 34,461評論 4 333
  • 正文 年R本政府宣布,位于F島的核電站墓赴,受9級特大地震影響竞膳,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜诫硕,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 40,094評論 3 317
  • 文/蒙蒙 一坦辟、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧章办,春花似錦锉走、人聲如沸滔吠。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,850評論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽疮绷。三九已至,卻和暖如春嚣潜,著一層夾襖步出監(jiān)牢的瞬間冬骚,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 32,082評論 1 267
  • 我被黑心中介騙來泰國打工懂算, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留只冻,地道東北人。 一個月前我還...
    沈念sama閱讀 46,571評論 2 362
  • 正文 我出身青樓计技,卻偏偏與公主長得像喜德,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子垮媒,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,666評論 2 350

推薦閱讀更多精彩內(nèi)容

  • 轉(zhuǎn)自[深入理解Android卷一全文-第十章]深入理解MediaScanner本章主要內(nèi)容 介紹多媒體系統(tǒng)中媒體文...
    yyg閱讀 3,903評論 0 0
  • 在看到本文之前舍悯,如果讀者沒看過筆者的前文 獲取Android源碼,初識源碼模塊架構(gòu)睡雇,請先翻閱萌衬。 JNI: Java...
    Louis_陸閱讀 773評論 0 2
  • Android Q 越來越近了,最近 Google 又發(fā)布了 Android Q Beta 的第五個版本它抱,眼瞅著這...
    hljstardust閱讀 4,987評論 0 3
  • 轉(zhuǎn)自:原文 這篇文章從系統(tǒng)源代碼分析秕豫,講述如何將程序創(chuàng)建的多媒體文件加入系統(tǒng)的媒體庫,如何從媒體庫刪除观蓄,以及大多數(shù)...
    yyg閱讀 4,133評論 1 6
  • https://blog.csdn.net/bobo89455100/article/category/66048...
    GrovessevorG閱讀 219評論 0 0