前言
在前文使用ML Kit 实现相册智能场景分类 (一)中我们结合 ML Kit 的 image-labeling 组件实现了一个图片选择器场景智能分类的 demo。但是,其实还有一些细节需要完善,有一些问题需要解决,下面就从实际使用的角度出发看看有哪些问题,以及如何解决。
存在的问题
- 对本地的所有图片每次进行检测需要耗费大量的时间,如何在用户选择图片时立即可用?
- 图库内容发生变化又该如何处理?
对于这两个问题,我们尝试按照下面的方式解决。
缓存图片标签信息
首先是图片检测耗时的问题,针对这种类型的问题,我们很容易想到的办法就是缓存。把之前一次检测的结果保存下来,用户使用的时候,优先展示上次使用的内容,同时在后台进行数据更新。
kotlin
private fun saveLabels(context: Context, labelList: ArrayList<Labels>) {
val sp = context.getSharedPreferences(LABEL_CATEGORIES_FILE, Context.MODE_PRIVATE)
val value = JsonUtil.toJson(labelList)
sp.edit().putString(LABEL_KEY, value).apply()
}
private fun getLabels(context: Context): List<Labels> {
val sp = context.getSharedPreferences(LABEL_CATEGORIES_FILE, Context.MODE_PRIVATE)
val value = sp.getString(LABEL_KEY, "")
if (!TextUtils.isEmpty(value)) {
val tempList = JsonUtil.toObjList(value!!, Labels::class.java)
tempList.forEach {
val labelSubList = it.subs
val iterator = labelSubList.iterator()
while (iterator.hasNext()) {
val uri = iterator.next()
val filePath = File(FileUtils.getFilePathByUri(context, uri))
if (filePath.exists().not()) {
Log.i(TAG, "$uri,$filePath not exist,removed")
iterator.remove()
}
}
it.subs = labelSubList
}
return tempList
}
return emptyList()
}
这里需要注意的是,当我们读取直接保存的图片标签信息时,有些图片可能已经被删除了。因此,在返回地址时需要过滤掉不存在的图片 URL,避免 UI 层展示的出现问题,造成 bug。当手机里有上千张照片时,这个 getLabels 方法的执行时间会变得很客观,因此特别注意的是需要在异步线程执行这个方法。
按照上面加缓存的方式,虽然可以解决用户每次使用时需要等待的问题,但是如果本地相册的内容经常发生变动,尤其是有新增照片的情况,就会出现数据更新不及时的问题,用户进行选择的时候新加的照片不在智能场景分类的集合里,就会感觉有 bug 。对于这个问题,显而易见的做法就是在用户使用之前完成数据的更新,但是按照现在的思路是在用户打开应用使用的时候进行更新,那么有没有办法通过其他方式更新数据呢?
获取相册数据
对于相册内容发生变化这件事情,非系统级别的应用其实没有什么办法,无论是通过 Loader 主动查询还是通过 ContentResolver 监听系统相册的变化,都依赖 Context。而这个提供 Context 的进程一旦被杀掉,数据也就缺失了。因此,系统相册的数据只能依赖应用进程存在的时候获取,然后进行存储,算是一种折中的办法吧。
由于 image-labeling 只能处理图片类型的数据,对于动图、视频之类的媒体是无法进行处理的,因此需要获取合适的数据,避免对无限数据进行处理。
自定义 Loader 获取数据
kotlin
class GalleryMediaLoader private constructor(context: Context, selection: String, selectionArgs: Array<String>) :
CursorLoader(context, QUERY_URI, PROJECTION, selection, selectionArgs, ORDER_BY) {
companion object {
private val QUERY_URI = MediaStore.Files.getContentUri("external")
private val PROJECTION = arrayOf(
MediaStore.Files.FileColumns._ID, MediaStore.MediaColumns.MIME_TYPE, MediaStore.MediaColumns.SIZE
)
private const val SELECTION_ALL_FOR_SINGLE_MEDIA_TYPE =
MediaStore.Files.FileColumns.MEDIA_TYPE + "=?" + " AND " + MediaStore.MediaColumns.SIZE + ">0"
private const val ORDER_BY = MediaStore.Images.Media.DATE_ADDED + " DESC"
fun newInstance(context: Context): CursorLoader {
val selection = SELECTION_ALL_FOR_SINGLE_MEDIA_TYPE
val selectionArgs = arrayOf(MediaStore.Files.FileColumns.MEDIA_TYPE_IMAGE.toString())
return GalleryMediaLoader(context, selection, selectionArgs)
}
}
}
这里自定义一个 GalleryMediaLoader ,通过配置构造函数的参数,只查询图片类型的数据。
kotlin
class PhotoLoader(private val context: Context, private val callback: (ArrayList<Uri>) -> Unit) :
LoaderManager.LoaderCallbacks<Cursor> {
private val TAG = "PhotoLoader"
override fun onCreateLoader(id: Int, args: Bundle?): Loader<Cursor> {
return GalleryMediaLoader.newInstance(context)
}
override fun onLoaderReset(loader: Loader<Cursor>) {
}
override fun onLoadFinished(loader: Loader<Cursor>, data: Cursor?) {
val uriList = ArrayList<Uri>()
if (data!!.moveToFirst()) {
do {
val uri = getUri(data)
uriList.add(uri)
} while (data.moveToNext())
}
callback(uriList)
Log.d(TAG, "total = " + uriList.size)
}
@SuppressLint("Range")
private fun getUri(cursor: Cursor): Uri {
val id = cursor.getLong(cursor.getColumnIndex(MediaStore.Files.FileColumns._ID))
val contentUri = MediaStore.Images.Media.EXTERNAL_CONTENT_URI
return ContentUris.withAppendedId(contentUri, id)
}
}
使用 LoaderManager 初始化上面定义的 Loader,并在合适的触发这个 Loader 的查询即可。当查询结果通过回调函数返回的时候,我们就可以把这部分数据缓存下来,交给后续的识别任务去处理。
周期性的进行图像标签识别
由于系统相册的信息只能在运行时获取,但是前面也提到了识别图像标签是一个耗时的过程,如果每次获取到相册信息之后都进行扫描,瞬时会占用大量的 CPU 和内存资源,导致用户体验变差。
对于这种场景,我们可以借助 WorkManager,之前的文章 Android WorkManager 周期性任务的使用 已经提到,使用这个官方提供的组件,可以执行一些周期性的任务。而且由于这个组件是官方支持的,就不用考虑保活、重试这些逻辑,直接使用组件提供的 API 进行合理的配置就可以了。
我们可以使用 WorkManager 创建一个周期性的任务,这个任务做两件事
- 通过 image-labeling 组件对所有图片进行标签识别,
- 基于识别结果进行聚合处理,更新本地缓存的数据
这样用户每次打开之后,很大概率就可以获取到最新的数据,只要设置任务执行的周期性时间即可。
周期性的查询任务
接下来就是创建周期性的任务
kotlin
class ScanUrlWork(private val appContext: Context, workerParameters: WorkerParameters) :
CoroutineWorker(appContext, workerParameters) {
override suspend fun doWork(): Result {
readAndParse(appContext)
return Result.success()
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return ForegroundInfo(
1, createNotification(
applicationContext, id, applicationContext.getString(R.string.app_name)
)
)
}
}
fun readAndParse(context: Context) {
val uris = UriManager.getLocalUri(context)
if (uris.isNotEmpty()) {
ImageLabelHelper.getLabel(context, uris)
}
}
ScanUrlWork 这个任务,通过 UriManager 获取缓存在本地的图片地址,当获取到图片地址之后,再使用之前创建好的 ImageLabelHelper 对所有图片获取标签和聚类的处理,最后完成本地缓存数据的更新。这样,只要这个任务能够执行,就可以确保本地缓存的图库标签信息是最新的。
配置周期性任务
kotlin
fun createWorkRequest(): PeriodicWorkRequest {
val constraints = Constraints.Builder().setRequiredNetworkType(NetworkType.NOT_REQUIRED)
.setRequiresBatteryNotLow(true).build()
return PeriodicWorkRequestBuilder<ScanUrlWork>(6, TimeUnit.HOURS).setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.LINEAR, 30, TimeUnit.SECONDS).addTag(WORK_TAG).build()
}
由于这是一个在本地进行的处理,因此不需要网络;如果手机上的照片很多,那么这个任务每次执行的时间会比较长,而且 image-labeling 组件进行这类推理也会比较耗费性能,因此在电量低的时候不执行任务。最后配置一个每 6 小时执行一次的周期性任务。这样每天会大概执行 4 次,可以基于这个数据做一下测试,看看是否可以恰好保持数据的更新。
kotlin
fun triggerWork(context: Context) {
val request = createWorkRequest()
WorkManager.getInstance(context).enqueueUniquePeriodicWork(
WORK_TAG, ExistingPeriodicWorkPolicy.KEEP, request
)
}
这里根据任务的 tag 确保全局只有这一个任务,重复调用不会被再次触发,这样就确保了这个周期性任务只有一个。最后在合适的时机调用这个任务即可。
其他
至此,通过对图片标签添加持久化的缓存和周期性任务的处理,基本上可以确保在使用时可以获取到完整的相册智能场景分类信息了。对于 image-labeling 组件获取到的信息,现在只是选择了第一个标签,其实可以根据其他的标签做更完善的聚类处理,毕竟有些图片包含的信息是非常丰富的,纯粹用一个标签是无法完善的表达这个图片的内容的。
image-labeling 的缺陷
image-labeling 组件本质上只能处理一张图像的信息,因此动图类型的图片,无论是 gif 还是 webp ,对这类图像识别的标签往往只是第一帧的结果,无法表达整张动图的信息。