Android之媒体扫描

媒体扫描时序图

在这里插入图片描述
看了上面的时序图是否感觉比较绕,一会Java层,一会Native层。其实只要了解它们为什么这样做就比较好理解为什么这样做了。
第一次:扫描路径,查找媒体文件,找到媒体文件之后就告知上层
第二次:上层收到Native层已找到媒体文件后,经过初步处理,再调用Native层去解析媒体文件。解析完成后就把
解析数据上报给Java层,然后插入数据库。

媒体扫描之代码分析

MediaScannerReceiver.java
    @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 internal only.
            scan(context, MediaProvider.INTERNAL_VOLUME);
        } else {
        		//外部存储挂载后,开始扫描外部存储
                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);
                }
            }
        }
    }

该广播属于静态广播,当接收到启动完成和外部存储挂载成功后开始扫描文件。本次咱们直接分析外部存储挂载成功后的媒体扫描。

MediaScannerReceiver.java
    private void scan(Context context, String volume) {
        Bundle args = new Bundle();
        args.putString("volume", volume);
        //携带volume参数,就是用来标志内部和外部存储。然后启动MediaScannerService
        context.startService(
                new Intent(context, MediaScannerService.class).putExtras(args));
    } 

MediaScannerService创建时会执行onCreate、onStartCommand,等MediaScannerService创建完成后,再次调用startService时,不在执行onCreate,但是会执行onStartCommand.下面咱们看看这两个生命周期方法都做了些什么。

MediaScannerService.java
    @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做的事件有两件事:
1.获取电源锁,防止在媒体扫描的时候休眠
2.创建线程并开启
那么在线程中又做了什么事情呢?其实就使用子线程的Looper创建一个Handler,代码就不看,有兴趣的自行分析。
下面看看Handler做了些什么?

MediaScannerService.java
private final class ServiceHandler extends Handler {
        @Override
        public void handleMessage(Message msg) {
            Bundle arguments = (Bundle) msg.obj;
            if (arguments == null) {
                Log.e(TAG, "null intent, b/20953950");
                return;
            }
            String filePath = arguments.getString("filepath");
            
            try {
            //扫描文件时走这里
                if (filePath != null) {
                    IBinder binder = arguments.getIBinder("listener");
                    IMediaScannerListener listener = 
                            (binder == null ? null : IMediaScannerListener.Stub.asInterface(binder));
                    Uri uri = null;
                    try {
                        uri = scanFile(filePath, arguments.getString("mimetype"));
                    } catch (Exception e) {
                        Log.e(TAG, "Exception scanning file", e);
                    }
                    if (listener != null) {
                        listener.scanCompleted(filePath, uri);
                    }
                } else {//扫描内部和外部存储走这里
                    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",
                        };
                    }
                    //扫描外部存储
                    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;
                        }
                    }

                    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);
                    }
                }
            } catch (Exception e) {
                Log.e(TAG, "Exception in handleMessage", e);
            }

            stopSelf(msg.arg1);
        }
    };

那什么时候开始执行handler呢?刚才咱们说了MediaScannerService的生命周期,咱们仅看到了onCreate,现在看看onStartCommand做了什么事情。

MediaScannerService.java
    @Override
    public int onStartCommand(Intent intent, int flags, int startId) {
    	//确保Handler不为null
        while (mServiceHandler == null) {
            synchronized (this) {
                try {
                    wait(100);
                } catch (InterruptedException e) {
                }
            }
        }

        if (intent == null) {
            Log.e(TAG, "Intent is null in onStartCommand: ",
                new NullPointerException());
            return Service.START_NOT_STICKY;
        }
		//Handler就是在这里发送的Message
        Message msg = mServiceHandler.obtainMessage();
        msg.arg1 = startId;
        msg.obj = intent.getExtras();
        mServiceHandler.sendMessage(msg);

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

下面再接着Handler处理看,Handler接收到外部存储的扫描消息后就看是执行scan了。

MediaScannerService.java
    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);
			//MSS通过insert这个特殊Uri让MediaProvider做一些准备工作
            Uri scanUri = getContentResolver().insert(MediaStore.getMediaScannerUri(), values);
			//向系统发送开始扫描的广播
            sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_STARTED, uri));

            try {
				//	如果是外部存储的话,通过insert打开meidaProvider数据库
                if (volumeName.equals(MediaProvider.EXTERNAL_VOLUME)) {
					//此方法仅仅是把volumeName插入数据库
                    openDatabase(volumeName);
                }
				//创建MediaScanner对象,在创建的过程中会做一些初始化的操作,例如更新当前系统使用的语言等。
                try (MediaScanner scanner = new MediaScanner(this, volumeName)) {
					//扫描路径
                    scanner.scanDirectories(directories);
                }
            } catch (Exception e) {
                Log.e(TAG, "exception in MediaScanner.scan()", e);
            }
			//通过特殊Uri做一些收尾清理工作
            getContentResolver().delete(scanUri, null, null);

        } finally {
			//向系统发送扫描完成的广播
            sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_FINISHED, uri));
            mWakeLock.release();
        }
    }

本章的贴的代码里注释的都比较清楚,所以就不做过多的解释。

    public void scanDirectories(String[] directories) {
        try {
            prescan(null, true);                ------------1

            if (ENABLE_BULK_INSERTS) {
                // create MediaInserter for bulk inserts
                mMediaInserter = new MediaInserter(mMediaProvider, 500);
            }

            for (int i = 0; i < directories.length; i++) {
				/**
				processDirectory是一个native方法,调用它来对目标文件进行扫描,其中
				mClient为MyMediaScannerClient类型,它是从MediaScannerClient类派生的
				**/
                processDirectory(directories[i], mClient);---------------2
            }

            if (ENABLE_BULK_INSERTS) {
                // flush remaining inserts
                mMediaInserter.flushAll();
                mMediaInserter = null;
            }
			//扫描后的处理
            postscan(directories);                         ----------------3

下面显卡preSande 的

/**
	上面 prescan 函数比较关键,首先让我们试想一个问题。

	在媒体扫描过程中,有个令人头疼的问题,来举个例子:
	假设某次扫描前 SD 卡中有 100 个媒体文件,数据库中
	会有 100 条关于这些文件的记录。现删除其中的 50 个
	文件,那么媒体数据库什么时候会被更新呢?

	MediaScanner 考虑到了这一点,prescan 函数的主要作用
	就是在扫描之前把上次扫描获取的数据库信息取出遍历并检
	测是否丢失,如果丢失,则从数据库中删除
	**/
    private void prescan(String filePath, boolean prescanFiles) throws RemoteException {
        Cursor c = null;
        String where = null;
        String[] selectionArgs = null;

		//用于保存从数据库中获取的信息
        mPlayLists.clear();

        if (filePath != null) {
            // query for only one file
            where = MediaStore.Files.FileColumns._ID + ">?" +
                " AND " + Files.FileColumns.DATA + "=?";
            selectionArgs = new String[] { "", filePath };
        } else {
            where = MediaStore.Files.FileColumns._ID + ">?";
            selectionArgs = new String[] { "" };
        }

		//获取数据库的值并赋值给对应的变量
        mDefaultRingtoneSet = wasRingtoneAlreadySet(Settings.System.RINGTONE);
        mDefaultNotificationSet = wasRingtoneAlreadySet(Settings.System.NOTIFICATION_SOUND);
        mDefaultAlarmSet = wasRingtoneAlreadySet(Settings.System.ALARM_ALERT);

        // Tell the provider to not delete the file.
        // If the file is truly gone the delete is unnecessary, and we want to avoid
        // accidentally deleting files that are really there (this may happen if the
        // filesystem is mounted and unmounted while the scanner is running).
        Uri.Builder builder = mFilesUri.buildUpon();
		//通过分析此处设置为false是跳过删除
        builder.appendQueryParameter(MediaStore.PARAM_DELETE_DATA, "false");
        MediaBulkDeleter deleter = new MediaBulkDeleter(mMediaProvider, builder.build());

        // Build the list of files from the content provider
        try {
            if (prescanFiles) {
                // First read existing files from the files table.
                // Because we'll be deleting entries for missing files as we go,
                // we need to query the database in small batches, to avoid problems
                // with CursorWindow positioning.
                long lastId = Long.MIN_VALUE;
                Uri limitUri = mFilesUri.buildUpon().appendQueryParameter("limit", "1000").build();

                while (true) {
                    selectionArgs[0] = "" + lastId;
                    if (c != null) {
                        c.close();
                        c = null;
                    }
					//查询数据库
                    c = mMediaProvider.query(limitUri, FILES_PRESCAN_PROJECTION,
                            where, selectionArgs, MediaStore.Files.FileColumns._ID, null);
                    if (c == null) {
                        break;
                    }

                    int num = c.getCount();

                    if (num == 0) {
                        break;
                    }
                    while (c.moveToNext()) {
                        long rowId = c.getLong(FILES_PRESCAN_ID_COLUMN_INDEX);
                        String path = c.getString(FILES_PRESCAN_PATH_COLUMN_INDEX);
                        int format = c.getInt(FILES_PRESCAN_FORMAT_COLUMN_INDEX);
                        long lastModified = c.getLong(FILES_PRESCAN_DATE_MODIFIED_COLUMN_INDEX);
                        lastId = rowId;

                        // Only consider entries with absolute path names.
                        // This allows storing URIs in the database without the
                        // media scanner removing them.
                        if (path != null && path.startsWith("/")) {
                            boolean exists = false;
                            try {
								//检查文件是否存在
                                exists = Os.access(path, android.system.OsConstants.F_OK);
                            } catch (ErrnoException e1) {
                            }
                            if (!exists && !MtpConstants.isAbstractObject(format)) {
                                // do not delete missing playlists, since they may have been
                                // modified by the user.
                                // The user can delete them in the media player instead.
                                // instead, clear the path and lastModified fields in the row
                                //获取文件类型
                                MediaFile.MediaFileType mediaFileType = MediaFile.getFileType(path);
                                int fileType = (mediaFileType == null ? 0 : mediaFileType.fileType);

                                if (!MediaFile.isPlayListFileType(fileType)) {
									//如果不是播放的文件类型,则删除它
                                    deleter.delete(rowId);
									//如果以"/.nomedia"结束,则表示已经无文件,则删除所有
                                    if (path.toLowerCase(Locale.US).endsWith("/.nomedia")) {
                                        deleter.flush();
                                        String parent = new File(path).getParent();
									//告诉MediaProvider重新扫描
                                        mMediaProvider.call(MediaStore.UNHIDE_CALL, parent, null);
                                    }
                                }
                            }
                        }
                    }
                }
            }
        }
        finally {
            if (c != null) {
                c.close();
            }
            deleter.flush();
        }

        // compute original size of images
        mOriginalCount = 0;
        c = mMediaProvider.query(mImagesUri, ID_PROJECTION, null, null, null, null);
        if (c != null) {
            mOriginalCount = c.getCount();
            c.close();
        }
    }

下面开始处理路径信息了

static void
android_media_MediaScanner_processDirectory(
        JNIEnv *env, jobject thiz, jstring path, jobject client)
{
    ALOGV("processDirectory");
	//获取在native_setup创建的MediaScanncer对象
    MediaScanner *mp = getNativeScanner_l(env, thiz);
	//通过Java层传递的MyMediaScannerClient构造一个native层MyMediaScannerClient
	//其实主要目的还是通过它调用Java层的方法
    MyMediaScannerClient myClient(env, client);
	//扫描路径
    MediaScanResult result = mp->processDirectory(pathStr, myClient);
}
MediaScanResult MediaScanner::processDirectory(
        const char *path, MediaScannerClient &client) {
    //获取路径长度,长度不能超过4096
    int pathLength = strlen(path);
	//设置本地语言,以便于在解析media信息时尽量转换成对应的语言信息
    client.setLocale(locale());
    MediaScanResult result = doProcessDirectory(pathBuffer, pathRemaining, client, false);
}
MediaScanResult MediaScanner::doProcessDirectory(
        char *path, int pathRemaining, MediaScannerClient &client, bool noMedia) {
    // place to copy file or directory name
    char* fileSpot = path + strlen(path);
    struct dirent* entry;

	//如果扫描的路径在白名单列表中,则直接返回不做任何处理。
    if (shouldSkipDirectory(path)) {
        ALOGD("Skipping: %s", path);
        return MEDIA_SCAN_RESULT_OK;
    }

    // Treat all files as non-media in directories that contain a  ".nomedia" file
    if (pathRemaining >= 8 /* strlen(".nomedia") */ ) {
        strcpy(fileSpot, ".nomedia");
		//如果存在“.nomedia”,则表示没有媒体文件
        if (access(path, F_OK) == 0) {
            ALOGV("found .nomedia, setting noMedia flag");
            noMedia = true;
        }

        // restore path
        fileSpot[0] = 0;
    }
	//打开目录
    DIR* dir = opendir(path);
    MediaScanResult result = MEDIA_SCAN_RESULT_OK;
    while ((entry = readdir(dir))) {//遍历整个目录
    	//处理遍历的子文件或子文件夹
        if (doProcessDirectoryEntry(path, pathRemaining, client, noMedia, entry, fileSpot)
                == MEDIA_SCAN_RESULT_ERROR) {
            result = MEDIA_SCAN_RESULT_ERROR;
            break;
        }
    }
    closedir(dir);
    return result;
}
MediaScanResult MediaScanner::doProcessDirectoryEntry(
        char *path, int pathRemaining, MediaScannerClient &client, bool noMedia,
        struct dirent* entry, char* fileSpot) {
    struct stat statbuf;
    const char* name = entry->d_name;//获取子文件夹或子文件的名称

    // ignore "." and ".."
    if (name[0] == '.' && (name[1] == 0 || (name[1] == '.' && name[2] == 0))) {
        return MEDIA_SCAN_RESULT_SKIPPED;
    }

    int nameLength = strlen(name);
    if (nameLength + 1 > pathRemaining) {
        // path too long!
        return MEDIA_SCAN_RESULT_SKIPPED;
    }
	//之前fileSpot指向的时搜索路径的末尾,现在指向子文件夹或子文件的全路径
    strcpy(fileSpot, name);

    int type = entry->d_type;
    if (type == DT_UNKNOWN) {
        // If the type is unknown, stat() the file instead.
        // This is sometimes necessary when accessing NFS mounted filesystems, but
        // could be needed in other cases well.
        if (stat(path, &statbuf) == 0) {
            if (S_ISREG(statbuf.st_mode)) {
                type = DT_REG;
            } else if (S_ISDIR(statbuf.st_mode)) {
                type = DT_DIR;
            }
        } else {
            ALOGD("stat() failed for %s: %s", path, strerror(errno) );
        }
    }
	//文件夹
    if (type == DT_DIR) {
        bool childNoMedia = noMedia;
        // set noMedia flag on directories with a name that starts with '.'
        // for example, the Mac ".Trashes" directory
        if (name[0] == '.')
            childNoMedia = true;

        // report the directory to the client
        if (stat(path, &statbuf) == 0) {//此path是一个文件,开始扫描文件
            status_t status = client.scanFile(path, statbuf.st_mtime, 0,
                    true /*isDirectory*/, childNoMedia);
            if (status) {
                return MEDIA_SCAN_RESULT_ERROR;
            }
        }
		//执行到这表示path依然是一个路径,所以继续递归doProcessDirectory
        // and now process its contents
        strcat(fileSpot, "/");
        MediaScanResult result = doProcessDirectory(path, pathRemaining - nameLength - 1,
                client, childNoMedia);
        if (result == MEDIA_SCAN_RESULT_ERROR) {
            return MEDIA_SCAN_RESULT_ERROR;
        }
    } else if (type == DT_REG) {
        stat(path, &statbuf);
        status_t status = client.scanFile(path, statbuf.st_mtime, statbuf.st_size,
                false /*isDirectory*/, noMedia);
        if (status) {
            return MEDIA_SCAN_RESULT_ERROR;
        }
    }

    return MEDIA_SCAN_RESULT_OK;
}

由于client.scanFile就是调用MediaScanner.java的scanFile,且scanFile又直接调用doScanFile,所以下面就直接看MediaScanner.java的doScanFile代码

//scanAlways用于控制是否强制扫描。有时候一些文件在前后两次扫描过程中没有发生变化,这时候
		//MediaScanner可以不处理这些文件。如果为true,则即使没有发生变化也要扫描。
        public Uri doScanFile(String path, String mimeType, long lastModified,
                long fileSize, boolean isDirectory, boolean scanAlways, boolean noMedia) {
                //做一些初始化工作。查询数据库,判断上传的路径信息并构建FileEntry
                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 {//存在有效的媒体文件
                        String lowpath = path.toLowerCase(Locale.ROOT);
						//看看是哪种目录类型
                        boolean ringtones = (lowpath.indexOf(RINGTONES_DIR) > 0);
                        boolean notifications = (lowpath.indexOf(NOTIFICATIONS_DIR) > 0);
                        boolean alarms = (lowpath.indexOf(ALARMS_DIR) > 0);
                        boolean podcasts = (lowpath.indexOf(PODCAST_DIR) > 0);
                        boolean music = (lowpath.indexOf(MUSIC_DIR) > 0) ||
                            (!ringtones && !notifications && !alarms && !podcasts);
						//判断是哪种文件类型
                        boolean isaudio = MediaFile.isAudioFileType(mFileType);
                        boolean isvideo = MediaFile.isVideoFileType(mFileType);
                        boolean isimage = MediaFile.isImageFileType(mFileType);

                        if (isaudio || isvideo || isimage) {
                            path = Environment.maybeTranslateEmulatedPathToInternal(new File(path))
                                    .getAbsolutePath();
                        }

                        // we only extract metadata for audio and video files
                        if (isaudio || isvideo) {
                            processFile(path, mimeType, this);//处理媒体文件
                        }

                        if (isimage) {//处理图片的,比较简单,就不做分析了
                            processImageFile(path);
                        }

                        result = endFile(entry, ringtones, notifications, alarms, music, podcasts);
        }

下面直接看processFile,中间的一些简单步骤省略了,如果不知道请查看时序图。

MediaScanResult StagefrightMediaScanner::processFile(
        const char *path, const char *mimeType,
        MediaScannerClient &client) {
    ALOGV("processFile '%s'.", path);
	//设置本地语言
    client.setLocale(locale());
	//空实现
    client.beginFile();
	//真正处理文件的是在这个地方
    MediaScanResult result = processFileInternal(path, mimeType, client);
    client.endFile();
    return result;
}
MediaScanResult StagefrightMediaScanner::processFileInternal(
        const char *path, const char * /* mimeType */,
        MediaScannerClient &client) {
        //查找path路径中最后一个“.”
    const char *extension = strrchr(path, '.');
    sp<MediaMetadataRetriever> mRetriever(new MediaMetadataRetriever);

    int fd = open(path, O_RDONLY | O_LARGEFILE);
    for (size_t i = 0; i < kNumEntries; ++i) {//循环解析各个属性
        const char *value;
        if ((value = mRetriever->extractMetadata(kKeyMap[i].key)) != NULL) {
			//通过调用JNI的client对象将解析到的属性传回Java层,虽然此时调用的
			//是addStringTag,但是只是一个中转,其实它调用了handleStringTag
            status = client.addStringTag(kKeyMap[i].tag, value);
}

直接看MediaScanner.java的handleStringTag,一目了然,就不做过多解释。

        public void handleStringTag(String name, String value) {
            if (name.equalsIgnoreCase("title") || name.startsWith("title;")) {
                // Don't trim() here, to preserve the special \001 character
                // used to force sorting. The media provider will trim() before
                // inserting the title in to the database.
                mTitle = value;
            } else if (name.equalsIgnoreCase("artist") || name.startsWith("artist;")) {
                mArtist = value.trim();
            } else if (name.equalsIgnoreCase("albumartist") || name.startsWith("albumartist;")
                    || name.equalsIgnoreCase("band") || name.startsWith("band;")) {
                mAlbumArtist = value.trim();
            } else if (name.equalsIgnoreCase("album") || name.startsWith("album;")) {
                mAlbum = value.trim();
            } else if (name.equalsIgnoreCase("composer") || name.startsWith("composer;")) {
                mComposer = value.trim();
            } else if (mProcessGenres &&
                    (name.equalsIgnoreCase("genre") || name.startsWith("genre;"))) {
                mGenre = getGenreName(value);
            } else if (name.equalsIgnoreCase("year") || name.startsWith("year;")) {
                mYear = parseSubstring(value, 0, 0);
            } else if (name.equalsIgnoreCase("tracknumber") || name.startsWith("tracknumber;")) {
                // track number might be of the form "2/12"
                // we just read the number before the slash
                int num = parseSubstring(value, 0, 0);
                mTrack = (mTrack / 1000) * 1000 + num;
            } else if (name.equalsIgnoreCase("discnumber") ||
                    name.equals("set") || name.startsWith("set;")) {
                // set number might be of the form "1/3"
                // we just read the number before the slash
                int num = parseSubstring(value, 0, 0);
                mTrack = (num * 1000) + (mTrack % 1000);
            } else if (name.equalsIgnoreCase("duration")) {
                mDuration = parseSubstring(value, 0, 0);
            } else if (name.equalsIgnoreCase("writer") || name.startsWith("writer;")) {
                mWriter = value.trim();
            } else if (name.equalsIgnoreCase("compilation")) {
                mCompilation = parseSubstring(value, 0, 0);
            } else if (name.equalsIgnoreCase("isdrm")) {
                mIsDrm = (parseSubstring(value, 0, 0) == 1);
            } else if (name.equalsIgnoreCase("date")) {
                mDate = parseDate(value);
            } else if (name.equalsIgnoreCase("width")) {
                mWidth = parseSubstring(value, 0, 0);
            } else if (name.equalsIgnoreCase("height")) {
                mHeight = parseSubstring(value, 0, 0);
            } else {
                //Log.v(TAG, "unknown tag: " + name + " (" + mProcessGenres + ")");
            }
        }

媒体信息都解析出来了,下面就该保存数据了。插入数据库的处理就在endFile里,这里不分析,有兴趣的自行分析。好了,本章就讲到这里。有什么问题请多多指点。

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

飞_哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值