Android MediaScanner MediaProvider流程以及性能优化,音视频扫描
Android MediaScanner MediaProvider流程以及性能优化,音视频扫描
- 快速扫描
- 一、源码解析
- github链接
- MediaScanner时序图
- MediaSacannerReeiver.java
- MediaScannerService.java
- MediaProvider.java
- MediaScanner.java
- MediaScanner.cpp
- StagefrightMediaScanner.cpp
- 二、配置修改
- 1、修改数据库路径
- 2、修改数据库WAL模式
- 三、存在的问题
- 四、性能优化
- 1、扫描方案优化
- 背景
- 快表流程
- 2、程序优化
快速扫描
传送们: 快速扫描程序.
快速扫描部分数据量大的情况下比Android原生的MediaScanner快几十倍(我车机原生两万多音视频第一次扫描十几分钟,快速扫描10秒),缺点就是没有专辑、艺术家列表。UI部分我做的很随意,主要还是想快速实现,代码也很乱,之后会整理好。
简单效果:
[video(video-fWcdsuHl-1601061165426)(type-bilibili)(url-https://player.bilibili.com/player.html?aid=839660626)(image-https://img-blog.csdnimg.cn/img_convert/749c9eca2d929b923733cc6b60275858.png)(title-快速扫描)
一、源码解析
github链接
添加了多屏操作和手势识别,向左滑动可以把视频放到副屏播放,向上滑动退出视频播放。
github,欢迎交流.
MediaScanner时序图
链接:
MediaScanner时序图.
MediaSacannerReeiver.java
主要负责service的启动,接收android.intent.action.MEDIA_MOUNTED广播启动MediaScannerService
// An highlighted blockprivate void scan(Context context, String volume) {Bundle args = new Bundle();args.putString("volume", volume);context.startService(new Intent(context, MediaScannerService.class).putExtras(args));}
MediaScannerService.java
第一次被启动走onCreate,将自己的线程启动
// A code block@Overridepublic 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();}
第二次启动走onStartCommand,从广播里面获取信息发送给mServiceHandler,通过handler通知service启动线程扫描。
// An highlighted block@Overridepublic int onStartCommand(Intent intent, int flags, int startId) {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;}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;}
mServiceHandler 解析路径和volume信息然后开始扫描:
// An highlighted blockprivate final class ServiceHandler extends Handler {@Overridepublic void handleMessage(Message msg) {...scan(directories, volume);...}};
scan(String[] directories, String volumeName)方法中先 openDatabase(volumeName);发消息给MedaiProvider,让数据库先准备好,然后MediaScanner scanner = new MediaScanner(this, volumeName),scanner.scanDirectories(directories);MediaScanner.java开始扫描。
MediaProvider.java
MediaScanner.java
具体流程可以在上面提供的时序图查看,这里主要讲解几个重要的方法:
1、prescan
prescan主要是做老数据删除,先从数据库将数据读取出来,然后判断文件存不存在,不存在就删除。
// An highlighted block
private void prescan(String filePath, boolean prescanFiles) throws RemoteException {Cursor c = null;String where = null;String[] selectionArgs = null;mPlayLists.clear();//清除列表,这个列表后面用来保存每个媒体问的信息:id,修改时间等if (filePath != null) {//获取单个数据// query for only one filewhere = MediaStore.Files.FileColumns._ID + ">?" +" AND " + Files.FileColumns.DATA + "=?";selectionArgs = new String[] { "", filePath };} else {//从数据库files表获取所有数据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();builder.appendQueryParameter(MediaStore.PARAM_DELETE_DATA, "false");MediaBulkDeleter deleter = new MediaBulkDeleter(mMediaProvider, builder.build());// Build the list of files from the content providertry {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;//每次操作限制读取1000个数据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();//获取到的数据个数判断是否为0,空的话就不用处理了if (num == 0) {break;}//对1000个数据进行处理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 rowMediaFile.MediaFileType mediaFileType = MediaFile.getFileType(path);int fileType = (mediaFileType == null ? 0 : mediaFileType.fileType);//添加要删除的iddeleter.delete(rowId);//如果.nomedia文件被删除了,那么就需要重新扫描这个文件夹,因为之前没有扫描。if (path.toLowerCase(Locale.US).endsWith("/.nomedia")) {//开始删除老数据deleter.flush();String parent = new File(path).getParent();mMediaProvider.call(MediaStore.UNHIDE_CALL, parent, null);}}}}}}}}finally {if (c != null) {c.close();}//开始删除老数据deleter.flush();}// compute original size of imagesmOriginalCount = 0;c = mMediaProvider.query(mImagesUri, ID_PROJECTION, null, null, null, null);if (c != null) {mOriginalCount = c.getCount();c.close();}}
2、beginFile
做一些准备工作,保存文件的一些信息,如id、最后修改时间、文件是否被更改等。
3、endFile
每个文件处理都会调用一次endfile,主要是来判别文件的类型,插入到对应的表,不过并不是每次都插入,MediaIsert.java文件会对插入的数据计数,超过250条数据就一起插入数据库,调用bulkInsert。
// An highlighted blockprivate Uri endFile(FileEntry entry, boolean ringtones, boolean notifications,boolean alarms, boolean music, boolean podcasts)throws RemoteException {// update database// use album artist if artist is missingif (mArtist == null || mArtist.length() == 0) {mArtist = mAlbumArtist;}//toValues保存专辑、艺术家、标题等信息,这些信息是从navie函数handleStringTag获取的,//这个函数是通过JNI:android_media_MediaScanner.cpp调用到MediaScannerClient的//成员函数,这个函数被MediaScannerClient::addStringTag包装,其实最终还是被//StagefrightMediaScanner.cpp调用,StagefrightMediaScanner中主要是从歌曲或者视频//文件读取专辑信息,然后通过addStringTag传递给Java侧MediaScanner.javaContentValues 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);}String album = values.getAsString(Audio.Media.ALBUM);if (MediaStore.UNKNOWN_STRING.equals(album)) {album = values.getAsString(MediaStore.MediaColumns.DATA);// extract last path segment before file nameint lastSlash = album.lastIndexOf('/');if (lastSlash >= 0) {int previousSlash = 0;while (true) {int idx = album.indexOf('/', previousSlash + 1);if (idx < 0 || idx >= lastSlash) {break;}previousSlash = idx;}if (previousSlash != 0) {album = album.substring(previousSlash + 1, lastSlash);values.put(Audio.Media.ALBUM, album);}}}//entry是在beginFile中就创建好,主要保存从数据库获取的该文件的信息,如果是新数据,//rowId就是0 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|| MediaFile.isRawImageFileType(mFileType)) && !mNoMedia) {ExifInterface exif = null;try {exif = new ExifInterface(entry.mPath);} catch (IOException ex) {// exif is null}if (exif != null) {float[] latlng = new float[2];if (exif.getLatLong(latlng)) {values.put(Images.Media.LATITUDE, latlng[0]);values.put(Images.Media.LONGITUDE, latlng[1]);}long time = exif.getGpsDateTime();if (time != -1) {values.put(Images.Media.DATE_TAKEN, time);} else {// If no time zone information is available, we should consider using// EXIF local time as taken time if the difference between file time// and EXIF local time is not less than 1 Day, otherwise MediaProvider// will use file time as taken time.time = exif.getDateTime();if (time != -1 && Math.abs(mLastModified * 1000 - time) >= 86400000) {values.put(Images.Media.DATE_TAKEN, time);}}int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, -1);if (orientation != -1) {// We only recognize a subset of orientation tag values.int degree;switch(orientation) {case ExifInterface.ORIENTATION_ROTATE_90:degree = 90;break;case ExifInterface.ORIENTATION_ROTATE_180:degree = 180;break;case ExifInterface.ORIENTATION_ROTATE_270:degree = 270;break;default:degree = 0;break;}values.put(Images.Media.ORIENTATION, degree);}}}Uri tableUri = mFilesUri;MediaInserter inserter = mMediaInserter;if (!mNoMedia) {//判断是什么类型的文件,同时创建对应的URIif (MediaFile.isVideoFileType(mFileType)) {tableUri = mVideoUri;} else if (MediaFile.isImageFileType(mFileType)) {tableUri = mImagesUri;} else if (MediaFile.isAudioFileType(mFileType)) {tableUri = mAudioUri;}}Uri result = null;boolean needToSetSettings = false;// Setting a flag in order not to use bulk insert for the file related with// notifications, ringtones, and alarms, because the rowId of the inserted file is// needed.if (notifications && !mDefaultNotificationSet) {if (TextUtils.isEmpty(mDefaultNotificationFilename) ||doesPathHaveFilename(entry.mPath, mDefaultNotificationFilename)) {needToSetSettings = true;}} else if (ringtones && !mDefaultRingtoneSet) {if (TextUtils.isEmpty(mDefaultRingtoneFilename) ||doesPathHaveFilename(entry.mPath, mDefaultRingtoneFilename)) {needToSetSettings = true;}} else if (alarms && !mDefaultAlarmSet) {if (TextUtils.isEmpty(mDefaultAlarmAlertFilename) ||doesPathHaveFilename(entry.mPath, mDefaultAlarmAlertFilename)) {needToSetSettings = true;}}if (rowId == 0) {if (mMtpObjectHandle != 0) {values.put(MediaStore.MediaColumns.MEDIA_SCANNER_NEW_OBJECT_ID, mMtpObjectHandle);}if (tableUri == mFilesUri) {int format = entry.mFormat;if (format == 0) {format = MediaFile.getFormatCode(entry.mPath, mMimeType);}values.put(Files.FileColumns.FORMAT, format);}// 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 {// updated fileresult = ContentUris.withAppendedId(tableUri, rowId);// path should never change, and we want to avoid replacing mixed cased paths// with squashed lower case pathsvalues.remove(MediaStore.MediaColumns.DATA);int mediaType = 0;if (!MediaScanner.isNoMediaPath(entry.mPath)) {int fileType = MediaFile.getFileTypeForMimeType(mMimeType);if (MediaFile.isAudioFileType(fileType)) {mediaType = FileColumns.MEDIA_TYPE_AUDIO;} else if (MediaFile.isVideoFileType(fileType)) {mediaType = FileColumns.MEDIA_TYPE_VIDEO;} else if (MediaFile.isImageFileType(fileType)) {mediaType = FileColumns.MEDIA_TYPE_IMAGE;} else if (MediaFile.isPlayListFileType(fileType)) {mediaType = FileColumns.MEDIA_TYPE_PLAYLIST;}values.put(FileColumns.MEDIA_TYPE, mediaType);}mMediaProvider.update(result, values, null, null);}if(needToSetSettings) {if (notifications) {setRingtoneIfNotSet(Settings.System.NOTIFICATION_SOUND, tableUri, rowId);mDefaultNotificationSet = true;} else if (ringtones) {setRingtoneIfNotSet(Settings.System.RINGTONE, tableUri, rowId);mDefaultRingtoneSet = true;} else if (alarms) {setRingtoneIfNotSet(Settings.System.ALARM_ALERT, tableUri, rowId);mDefaultAlarmSet = true;}}return result;}
MediaScanner.cpp
主要负责文件的扫描,深度扫描存储设备,每扫描到文件就通过JNI调用Java侧的scanFile方法。
StagefrightMediaScanner.cpp
对文件类型进行过滤,读取媒体文件信息如标题、艺术家等。
二、配置修改
1、修改数据库路径
2、修改数据库WAL模式
- 设置db.enableWriteAheadLogging();可以在MediaProvider写数据的时候,UI读数据不会被阻塞。读写不会阻塞,但是只允许同时只有一用户写。设置WAL模式可以提高数据库写速度,降低磁盘IO,但是读数据就会变慢。具体原理可以参考
链接: SQLite分析之WAL机制.
三、存在的问题
- Android MediaProvider框架对于手机扫描来说是很贴切的,但是对于汽车车载系统来说就不是很友好,因为车机可能需要用USB来存储媒体数据,USB不像手机内置SD卡不用频繁插拔,如果用户更换新的USB,那么从USB读取数据并写入数据库需要耗费很多时间,从用户的角度看,我插入U盘然后查找到指定歌曲的速度太慢的话是否能接受呢。所以针对这样的问题,接下来就是车载系统的一些关于USB扫描的探讨和优化。
问题:
1、对于不同USB,MediaProvider会保留不同的数据库,占用多余磁盘空间;
2、扫描时会读取音视频文件title等信息,读取文件磁盘IO会导致扫描速度变慢,原本需要5分钟,可能就变成20分钟;
3、prescan预扫描时候可以读取数据,并且prescan后也没有明确的广播通知,如果数据被大量删除,UI会读取到已经删除的数据;
4、扫描是顺序扫描,如果一直在扫描歌曲还没扫描到视频,那么视频要等好久才能检索到;
四、性能优化
1、扫描方案优化
背景
- 对于IO读写慢的问题是无法回避的,为什么要读取文件信息,因为需要添加歌曲视频的专辑标题等信息,在UI侧才能做成专辑等列表。但是从用户的角度分析,如果我U盘插入车机,要听音乐,我大体上用打开歌曲列表,或者文件夹,就可以快速找到自己想要播放的歌曲,或者是收藏列表。可能专辑风格艺术家等列表被打开的概率只有20%,但是这20%的概率却占用了扫描80%的时间,我觉得是不合理的,但是又不能不做,所以我觉得,可以先用很短的时间做成一个快表,这个快表能够提供歌曲视频列表,文件夹信息和收藏列表,之后再做专辑列表。我自己也尝试去做这个快表,如果整体扫描时间是20分钟的话,做快表的时间在5-15秒就可以完成,理论上可以达到5秒。等这个快表做成,就开始走正常的扫描流程,或者说两者一起并行运行也是可以的。
- 这个方案的缺陷就是CPU占比在一瞬间会比较高,而且原来扫描流程会慢几秒(我感觉可以省略)。
- 快速扫描功能包括预扫描删除没用的老数据、扫描数据时会判别是否存在、是否更改过,每个文件都保存上级文件夹的id,有一个歌曲表一个视频表和一个文件夹表。
快表流程
对插入的设备进行型号保存,如果是上次插入的设备,那么在扫描前要先做预扫描,删除数据库有但是存储设备中已经没有的数据,如果是新设备就直接扫描,扫描方式选择广度扫描。每扫描到一个文件就判别类型,只处理文件夹、音乐、视频,同时对媒体文件进行过滤(个数控制,支持播放类型过滤),获取父文件夹id。查看并保存文件修改时间,从数据库查询是否存在同名文件(数据库对绝对路径加索引),同名的话要对比修改时间,如果修改时间变了,就说明需要更新,如果不存在就要插入,写sql语句,保存:绝对路径、父文件夹id等信息,将对应sql语句插入到一个链表中(有两个链表,一个插入,一个存储)。当链表达到一定数量就通过事务方式写入数据库,开一个新线程来处理数据插入,主线程接着扫描,要加锁。
2、程序优化
数据库的写入是非常耗时的,提高磁盘io尺寸可以改善写入的时间,把要写入的数据先用数据结构保存起来,达到一定数量通过事务(TRANSACTION )写入,但是同时可能会导致写延时增加,双缓冲机制可以改善写延时,启动一个线程对第一个缓冲区的数据写入数据库,此时扫描到的数据写入第二个缓冲区,两个缓冲区交替使用;
在插入数据时会判断数据是否存在是否更新过,这个时候需要通过路径去数据库查找对应的绝对路径,正常情况下从数据库找到对应的绝对路径的平均时间复杂度为O(n),所以建议做个绝对路径的索引,将路径做排序,从该索引上就可以通过二分查找找到数据,此时平均时间复杂度为O(lgn)。索引就做这个就够用了,索引做太多会影响写入性能;
wal模式可以提高数据的写速度,并且在写入的时候可以读取;具体原理可以参考: SQLite分析之WAL机制.
快表扫描完后,应用层就可以通过快表做成所有歌曲和视频列表,还有文件夹列表,不过专辑、艺术家和风格等列表就需要在接下来的扫描里面更新到快表中去。第二遍扫描用Android原生的扫描,将歌曲和视频读取到内存中,然后解析出对应的专辑等信息…
多线程并发,双缓冲结构保存临时数据,写数据和扫描同时进行
title、专辑等信息在播放时候读取,扫描不读,转移磁盘IO时间
广度扫描,音乐和视频目录深度一般不深,更快的显示
所有操作都在native进行,减少JNI调用
快表的部分demo在github上,可以直接在手机上运行,由于功能精简专一,速度比原生扫描快100倍,没有USB,所以demo中只扫描sdcard中所有音乐,视频和文件夹,包括微信、网页等下载的视频和歌曲
Android MediaScanner MediaProvider流程以及性能优化,音视频扫描相关推荐
- Android手机内存管理与性能优化
Android手机内存管理与性能优化&JNI.NDK高级编程(JNI.Dalvik.内存监测) 课程分类:Android 适合人群:中级 课时数量:34小节课时 用到技术:Dalvik,DDM ...
- android edittext不可复制_精选Android中高级面试题:性能优化,JNI,设计模式
性能优化 1.图片的三级缓存中,图片加载到内存中,如果内存快爆了,会发生什么?怎么处理? 参考回答:首先我们要清楚图片的三级缓存是如何的: 如果内存足够时不回收.内存不够时就回收软引用对象 2.内存中 ...
- QQ音乐Android客户端Web页面通用性能优化实践
QQ音乐 Android 客户端的 Web 页面日均 PV 达到千万量级,然而页面的打开耗时与 Native 页面相距甚远,需要系统性优化.本文将介绍 QQ 音乐 Android 客户端在进行 Web ...
- webview加载的页面和浏览器渲染的页面不一致_QQ音乐Android客户端Web页面通用性能优化实践...
QQ音乐 Android 客户端的 Web 页面日均 PV 达到千万量级,然而页面的打开耗时与 Native 页面相距甚远,需要系统性优化.本文将介绍 QQ 音乐 Android 客户端在进行 Web ...
- web 折线图大数据量拉取展示方案_【第2010期】QQ音乐Android客户端Web页面通用性能优化实践...
前言 今日早读文章由QQ音乐客户端开发工程师@关岳分享,公号:云加社区(ID:QcloudCommunity,腾讯云官方开发者社区)授权分享. 正文从这开始~~ QQ音乐 Android 客户端的 W ...
- 理解Tomcat架构、启动流程及其性能优化
PS:but, it's bullshit ! 备注:实话说,从文档上扒拉的,文档地址:在每一个Tomcat安装目录下,会有一个webapps文件夹,里面有一个docs文件夹,点击index.html ...
- Spark SQL运行流程及性能优化:RBO和CBO
1 Spark SQL运行流程 1.1 Spark SQL核心--Catalyst Spark SQL的核心是Catalyst查询编译器,它将用户程序中的SQL/Dataset/DataFrame经过 ...
- android 程序等待时间,Android开发学习之路--性能优化之常用工具
Android性能优化相关的开发工具有很多很多种,这里对如下六个工具做个简单的使用介绍,主要有Android开发者选项,分析具体耗时的Trace view,布局复杂度工具Hierarchy View, ...
- 老白Oracle数据库性能优化实务-视频分享
http://www.400gb.com/u/2718690/4479328 老白Oracle数据库性能优化实务 课程风格: 理论结合实战案例,重点在于介绍优化的思路和工作方法.共享大量技术文档.脚本 ...
最新文章
- Android P 调用隐藏API限制原理
- js常用内建对象之:Math
- CentOS 6.9下KVM虚拟机通过virt-clone克隆虚拟机(转)
- DBLinq (MySQL exactly) Linq To MySql(转)
- 恒强系统服务器,恒强系统色码解析大全
- 制作学术PPT的注意事项如何制作模板(附模板下载链接)
- Java岗大厂面试百日冲刺 - 日积月累,每日三题【Day33】—— 手撸算法2
- gc日志一般关注什么_理解GC日志
- 洛谷3379-LCA-C++-(LCA+倍增)
- 聊聊A股市场反映情况
- 抖音壁纸小程序怎么做?手把手教你开通流量主拥有自己的壁纸小程序
- 没有区块链,就没有元宇宙
- Unity3D入门Demo-Cube移动-触发球体-切换场景
- V神·以太坊上的分片
- WangEditor富文本编辑器图片上传踩坑之路
- android获取网络时区_android 网络获取当前时区
- char 类型的操作函数
- OS相关驱动 Linux USB驱动框架分析
- BES2300x笔记(23) -- 10s的软件定时器
- python设置画布的大小_Python tkinter框架画布调整大小
热门文章
- 【前端】wangeditor源码修改,打包发布到npm,实现上传视频功能,得到视频的第一帧保存为封面,spring-boot+vue,axios实现文件上传,视频图片浏览
- 【收藏+原创】商业网站
- jupyter notebook 精华
- python退出多线程_退出python多线程编程的方法
- office WORD和EXCEL打字卡顿——解决方法
- android判断某文件下是否you_android判断图片类型 判断文件是否为图片文件 - 电脑常识 - 服务器之家...
- gtx1650和gtx1060哪个好
- 法律条文怎么翻译效果好
- 【算法】未知长度序列等概率采样
- Android基础知识 - AppbarLayout
- Android手机内存管理与性能优化