本文是源码分析类文章

如何为Compose Image提供网络图片加载支持?目前(Compose 1.0.5)最好的选择是使用图片框架Coil,Coil对Jetpack Compose相关的支持文档在这。

Compose内的Image组件类似于ImageView,仅支持从本地加载图片资源,要想从网络中获取图片并加载,我们首先就得要使用能够处理网络请求的框架,将远程图片资源载入到本地才行。目前主流的图片加载框架Picasso、Glide、Coil等,它们更多面对的仍是传统的View系统下,将图片加载到ImageView中并显示这样的应用场景,而不是为Compose量身打造的,基于此,Accompanist库曾提供了一些图片加载框架的扩展库,为Compose的Image显示网络图片进行简便支持。时过境迁,后来Coil为Image加载图片提供了相关支持,故Accompanist以前关于图片加载框架扩展的依赖都被废弃并不推荐使用了。

接下来我们将分析Accompanist曾经是如何对图片框架做扩展适配,使之能够与Compose配合工作的。

Picasso(in version 0.6.2)

Accompanist在0.3.0版本就提供了Picasso的支持,不过,在版本0.7.0该集成被移除(相关的pull参见https://github.com/google/accompanist/pull/253)

在0.6.2版本中,想要加载网络图片,你可能会使用如下代码:

PicassoImage(data = "http://..."modifier = Modifier.size(50.dp),
) { imageLoadState ->when(imageLoadState) {...}
}
CoilImage(data = "https://i.imgur.com/StXm8nf.jpg",contentDescription = null,onRequestCompleted = {println("LoadingCoilImage onRequestCompleted $it")},contentScale = ContentScale.Crop,modifier = Modifier.fillMaxWidth(),
) {...
}

在version 0.6.2中,加载远程图片的方法是使用专用的Image组件,使用Picasso框架的调用PicassoImage,使用Coil的则调用CoilImage,等等。它们都依赖于一个imageloader-core的核心库来进行图片加载,我们不难想象这个加载图片的方法,为了糅合各类框架,肯定要用不少泛型,事实上它长下面这样:

@Composable
fun <R : Any, TR : Any> ImageLoad(request: R,executeRequest: suspend (TR) -> ImageLoadState,modifier: Modifier = Modifier,requestKey: Any = request,transformRequestForSize: (R, IntSize) -> TR?,shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda,onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda,content: @Composable BoxScope.(imageLoadState: ImageLoadState) -> Unit
) {...
}

泛型R代表请求的值,这个值之所以是泛型,是因为实际上各种框架都支持多类型的图片加载请求,这个请求可能是基于一个URL的String,也可能单纯是一个resource的id,或者就是一个Bitmap,等等。泛型TR代表了不同图片框架内收集本次图片请求信息的实体类(或者是Builder),在Picasso中这个类叫RequestCreator,在Glide中这个类叫RequestBuilder。

我们继续观察它的实现:

@Composable
fun <R : Any, TR : Any> ImageLoad(request: R,executeRequest: suspend (TR) -> ImageLoadState,modifier: Modifier = Modifier,requestKey: Any = request,transformRequestForSize: (R, IntSize) -> TR?,shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda,onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda,content: @Composable BoxScope.(imageLoadState: ImageLoadState) -> Unit
) {// 三个rememberUpdatedState,目的是为了避免更改后重组val updatedOnRequestCompleted by rememberUpdatedState(onRequestCompleted)val updatedTransformRequestForSize by rememberUpdatedState(transformRequestForSize)val updatedExecuteRequest by rememberUpdatedState(executeRequest)// 这个state拿来缓存控件大小,因为控件大小要等到Compose内容传入constraints才能确定var requestSize by remember(requestKey) { mutableStateOf<IntSize?>(null) }// 重点,这里使用produceState将executeRequest返回的非Compose状态转换为一个State// 之所以连加载图片的过程都抽象成一个叫executeRequest的lambda,还是因为要糅合多个框架val loadState by produceState<ImageLoadState>(initialValue = ImageLoadState.Loading,key1 = requestKey,key2 = requestSize,) {// value一开始肯定被赋值为ImageLoadState.Loading,因为requestSize为空。// 当requestSize被赋值后,首先将开始执行transformRequestForSize这个lambda// 传入原来的request和新获得的size,要求返回一个类似RequestBuilder的结果value = requestSize?.let { updatedTransformRequestForSize(request, it) }?.let { transformedRequest ->// 这里传入刚才的RequestBuildertry {// 发起图片加载请求,这里可能会挂起updatedExecuteRequest(transformedRequest)} catch (e: CancellationException) {// We specifically don't do anything for the request coroutine being// cancelled: https://github.com/google/accompanist/issues/217// 如果我们响应了协程的CancellationException,让ImageLoadState变成了Error// 有可能会出问题,因为如果取消的协程在新协程完成后执行,// 会导致新的图片状态(Success)被上次取消的结果(Error)覆盖throw e} catch (e: Error) {// Re-throw all Errorsthrow e} catch (e: IllegalStateException) {// Re-throw all IllegalStateExceptionsthrow e} catch (t: Throwable) {// Anything else, we wrap in a Error state instance// 除了CancellationException、Error、IllegalStateException之外,// 其余的错误将会令状态转变为ErrorImageLoadState.Error(painter = null, throwable = t)// also内,加载完成,回调onRequestCompleted}.also(updatedOnRequestCompleted)} ?: ImageLoadState.Loading}BoxWithConstraints(modifier = modifier,propagateMinConstraints = true,) {val size = IntSize(width = if (constraints.hasBoundedWidth) constraints.maxWidth else -1,height = if (constraints.hasBoundedHeight) constraints.maxHeight else -1)if (requestSize == null ||(requestSize != size && shouldRefetchOnSizeChange(loadState, size))) {requestSize = size}content(loadState)}
}

ImageLoad的思路清晰明了:调用方告诉它如何build一个请求,并在使用图片框架的过程中产生ImageLoadState状态,它会把ImageLoadState转换为可以观察的State<ImageLoadState>

直接使用通用实现的缺点在于会产生很多模板代码,可以基于通用实现进行更简洁的封装,我们以特定的PicassoImage的实现为例进行分析:

// 这个API封装更彻底,不需要写when(state),直接在函数中传入error、loading的内容即可
@Composable
fun PicassoImage(data: Any,contentDescription: String?,modifier: Modifier = Modifier,alignment: Alignment = Alignment.Center,contentScale: ContentScale = ContentScale.Fit,colorFilter: ColorFilter? = null,fadeIn: Boolean = false,picasso: Picasso = LocalPicasso.current,requestBuilder: (RequestCreator.(size: IntSize) -> RequestCreator)? = null,shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda,onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda,error: @Composable (BoxScope.(ImageLoadState.Error) -> Unit)? = null,loading: @Composable (BoxScope.() -> Unit)? = null,
) {PicassoImage(data = data,modifier = modifier,requestBuilder = requestBuilder,picasso = picasso,shouldRefetchOnSizeChange = shouldRefetchOnSizeChange,onRequestCompleted = onRequestCompleted,) { imageState ->when (imageState) {is ImageLoadState.Success -> {// MaterialLoadingImage是0.6.2版本中存在的一个实现fadeIn效果的控件// 原理是使用Compose动画中的Transition托管三个动画// alpha(透明度),brightness(亮度),saturation(饱和度), // 同时修改传入Image内的colorFliter的这三个值,从而实现渐入效果MaterialLoadingImage(result = imageState,contentDescription = contentDescription,fadeInEnabled = fadeIn,alignment = alignment,contentScale = contentScale,colorFilter = colorFilter)}is ImageLoadState.Error -> if (error != null) error(imageState)ImageLoadState.Loading -> if (loading != null) loading()ImageLoadState.Empty -> Unit}}
}@Composable
fun PicassoImage(data: Any,modifier: Modifier = Modifier,picasso: Picasso = LocalPicasso.current,requestBuilder: (RequestCreator.(size: IntSize) -> RequestCreator)? = null,shouldRefetchOnSizeChange: (currentResult: ImageLoadState, size: IntSize) -> Boolean = DefaultRefetchOnSizeChangeLambda,onRequestCompleted: (ImageLoadState) -> Unit = EmptyRequestCompleteLambda,content: @Composable BoxScope.(imageLoadState: ImageLoadState) -> Unit
) {ImageLoad(request = data.toRequestCreator(picasso),requestKey = data, // Picasso RequestCreator doesn't support equality so we use the dataexecuteRequest = { r ->@OptIn(ExperimentalCoroutinesApi::class)suspendCancellableCoroutine { cont ->// 初始化了一个Target,这个Target用来获取图片加载结果val target = object : com.squareup.picasso.Target {override fun onBitmapLoaded(bitmap: Bitmap, from: Picasso.LoadedFrom) {val state = ImageLoadState.Success(painter = BitmapPainter(bitmap.asImageBitmap()),source = from.toDataSource())// 协程恢复cont.resume(state) {// Not much we can do here. Ignore this}}override fun onBitmapFailed(exception: Exception, errorDrawable: Drawable?) {val state = ImageLoadState.Error(throwable = exception,painter = errorDrawable?.toPainter(),)// 协程恢复cont.resume(state) {// Not much we can do here. Ignore this}}override fun onPrepareLoad(placeholder: Drawable?) = Unit}cont.invokeOnCancellation {// 取消图片加载picasso.cancelRequest(target)}// Now kick off the image load into our targetr.into(target)}},transformRequestForSize = { r, size ->val sizedRequest = when {// 如果尺寸包含未指定尺寸的尺寸,我们不会在Coil请求中指定尺寸size.width < 0 || size.height < 0 -> rsize != IntSize.Zero -> {r.resize(size.width, size.height).centerInside().onlyScaleDown()}// Otherwise we have a zero size, so no point executing a request// 未获得size,因此暂时无法生成请求else -> null}// 根据参数来build请求if (sizedRequest != null && requestBuilder != null) {// If we have a transformed request and builder, let it runrequestBuilder(sizedRequest, size)} else {// Otherwise we just return the sizedRequestsizedRequest}},shouldRefetchOnSizeChange = shouldRefetchOnSizeChange,onRequestCompleted = onRequestCompleted,modifier = modifier,content = content)
}

现在让我们来总结一下,在0.6.2版本,实现网络图片加载的集成库思路如下:

  1. 图片加载:使用Target回调获取加载的结果(各个框架都有类似的抽象的Target而不是限制目标必须是ImageView)。结果返回的过程是阻塞式,协程将在produceState内执行到updatedExecuteRequest(transformedRequest)后挂起,直到这个lambda返回结果,State的值将会在结果返回后产生变化。当然,如果协程被取消,Picasso也会取消加载到Target那个图片请求。
  2. 图片大小约束:依赖于BoxWithConstraints获得的约束大小。
  3. 渐入动画实现:使用动画API Transition对ColorFliter的alpha,brightness,saturation进行动态修改,从而实现渐入动画。
  4. loading占位图、error显示等:依赖于用户传入的@Composable内容.根据produceState生成的状态,PicassoImage内显示的@Composable内容会动态变化。

Glide(in version 0.13.0)

0.3.0版本诞生于2020年10月份,而当时间来到了2021年4月,Accompanist发布0.8.0版本,Coil 和 Glide 集成库进行了大规模的重构。上面提到的类似于CoilImage()GlideImage()API都已经被弃用了。

以下对Glide集成库的分析基于版本0.13.0的代码。

如果在0.13.0版本想要加载远程图片,或许你会写出以下的代码:

Image(painter = rememberGlidePainter(request = "http://..."),contentDescription = null
)

新的API不再需要专门的Image组件,而是使用Painter这种概念来表现加载的结果。新的API对性能的提升似乎有所提升:Compose内容重组后,需要重绘的不再是不同的Loading组件或Success组件,现在核心组件一定是一个Image,随加载状态变化的只不过是Image内绘制的内容而已,重绘范围有所缩小。这很符合我们对ImageView的想象:在加载的时候显示一张placeholder占位图,成功显示最终结果,否则显示error图片,而placeholder和error都可以发起图片加载请求的时候设置。

Painter是一个什么样的概念?我们可以先看一下类注释是怎么介绍它的:

/**
* 对可以画出来的东西的抽象。除了能够绘制到指定的有界区域外,Painter还提供了一些高级机制,消费者可以使用
* 这些机制来配置内容的绘制方式。其中包括alpha、ColorFilter和RTL
* 实现应该提供一个有意义的equals方法来比较不同Painter子类的值,而不仅仅依赖于引用相等
*/
abstract class Painter {...protected abstract fun DrawScope.onDraw()
}

描述看起来有点像Drawable,但实际上Drawable比Painter更加复杂一些,除了上述的alpha、ColorFilter、LayoutDirection之外,Drawable还具有动画Callback、Level、Hotspot等属性。DrawScope.onDraw()方法类似于Drawable的draw(Canvas canvas)

继续观察rememberGlidePainter的具体实现:

@Composable
fun rememberGlidePainter(request: Any?,requestManager: RequestManager = GlidePainterDefaults.defaultRequestManager(),shouldRefetchOnSizeChange: ShouldRefetchOnSizeChange = ShouldRefetchOnSizeChange { _, _ -> false },// 注意这里的requestBuilder,加载的结果类型已经被固定为drawablerequestBuilder: (RequestBuilder<Drawable>.(size: IntSize) -> RequestBuilder<Drawable>)? = null,// 新的API也能开启fadeIn效果fadeIn: Boolean = false,fadeInDurationMs: Int = LoadPainterDefaults.FadeInTransitionDuration,// 是不是很疑惑为什么这里有个占位图id的参数?Glide本身就支持占位图设置,// 在Build Request的时候设置不就行了吗?其实这个参数是给Compose预览模式用的@DrawableRes previewPlaceholder: Int = 0,
): LoadPainter<Any> {// GlideLoader是加载逻辑实现类,稍后展示val glideLoader = remember {GlideLoader(requestManager, requestBuilder)}.apply {// 这里的逻辑并不是多余的,要知道如果key没有变化,remember函数会直接返回上次计算的结果,// 这里想表达的是,对上次的结果调用apply,更新requestManager和requestBuilderthis.requestManager = requestManagerthis.requestBuilder = requestBuilder}// rememberLoadPainter位于之前所说的imageloading-core的核心库// 在0.13.0版本Coil和Glide都用到这个库来获取LoadPainterreturn rememberLoadPainter(loader = glideLoader,request = checkData(request),shouldRefetchOnSizeChange = shouldRefetchOnSizeChange,fadeIn = fadeIn,fadeInDurationMs = fadeInDurationMs,previewPlaceholder = previewPlaceholder)
}
// checkData检查了request的类型
private fun checkData(data: Any?): Any? {when (data) {is Drawable -> {throw IllegalArgumentException(....)}is ImageBitmap -> {throw IllegalArgumentException(....)}is ImageVector -> {throw IllegalArgumentException(....)}is Painter -> {throw IllegalArgumentException(....)}}return data
}

imageloading-core这次如何抽象图片加载行为?我们先观察一下rememberLoadPainter的参数列表:

@Composable
fun <R> rememberLoadPainter(loader: Loader<R>,request: R?,shouldRefetchOnSizeChange: ShouldRefetchOnSizeChange,fadeIn: Boolean = false,fadeInDurationMs: Int = LoadPainterDefaults.FadeInTransitionDuration,@DrawableRes previewPlaceholder: Int = 0,
): LoadPainter<R> {...}@Stable
fun interface Loader<R> {fun load(request: R, size: IntSize): Flow<ImageLoadState>
}

与0.6.2版本不同,加载逻辑实现类需要返回一个状态流Flow<ImageLoadState>,而不再是单一的ImageLoadState,虽然请求类型仍然是泛型的,但是已经不需要表达类似于RequestBuilder这样的泛型类型,如何构建、发起请求由Loader自己决定。

ImageLoadState的实现如下

sealed class ImageLoadState {object Empty : ImageLoadState()data class Loading(val placeholder: Painter?,val request: Any,) : ImageLoadState()data class Success(val result: Painter,val source: DataSource,val request: Any,) : ImageLoadState()data class Error(val request: Any,val result: Painter? = null,val throwable: Throwable? = null) : ImageLoadState()
}

不难发现所有的图片加载结果都要求封装成Painter进行返回,但尴尬的是,Drawable与Painter并不是天生互通的类型(Compose 1.0.5只有三种Painter,BitmapPainter、VectorPainter、ColorPainter),好在Accompanist提供了一个DrawablePainter。不过话又说回来,为什么非得要求生产者Loader返回Painter不可呢?那是因为加载请求是多类型的,消费者LoadPainter其实无法确定生产者返回的结果的类型,自然也不确定如何绘制它,因此LoadPainter采用了类似于装饰者模式的设计,图片结果绘制交由State内的Painter完成。

GlideLoader的实现如下:

internal class GlideLoader(requestManager: RequestManager,requestBuilder: (RequestBuilder<Drawable>.(size: IntSize) -> RequestBuilder<Drawable>)?,
) : Loader<Any> {var requestManager by mutableStateOf(requestManager)var requestBuilder by mutableStateOf(requestBuilder)/*** 不要删除callbackFlow上的显式类型<ImageLoadState>。IR编译器不喜欢隐式类型。*/@Suppress("RemoveExplicitTypeArguments")@OptIn(ExperimentalCoroutinesApi::class)override fun load(request: Any,size: IntSize): Flow<ImageLoadState> = callbackFlow<ImageLoadState> {var failException: Throwable? = null// 这里同时使用Target与Listener两种机制来监听加载状态,并向flow发送对应状态// Target并不会去处理Success的状态,Listener已经抢先处理并拦截了Target的Success调用val target = object : EmptyCustomTarget(if (size.width > 0) size.width else Target.SIZE_ORIGINAL,if (size.height > 0) size.height else Target.SIZE_ORIGINAL) {override fun onLoadStarted(placeholder: Drawable?) {trySendBlocking(ImageLoadState.Loading(placeholder = placeholder?.let(::DrawablePainter),request = request))}override fun onLoadFailed(errorDrawable: Drawable?) {trySendBlocking(ImageLoadState.Error(result = errorDrawable?.let(::DrawablePainter),request = request,throwable = failException?: IllegalArgumentException("Error while loading $request")))// Close the channel[Flow]channel.close()}override fun onLoadCleared(resource: Drawable?) {// Glide想要释放资源,所以我们需要清除结果,否则我们可能会绘制已经被回收的视图trySendBlocking(ImageLoadState.Empty)// Close the channel[Flow]channel.close()}}val listener = object : RequestListener<Drawable> {override fun onResourceReady(drawable: Drawable,model: Any,target: Target<Drawable>,dataSource: com.bumptech.glide.load.DataSource,isFirstResource: Boolean): Boolean {// 这里发送的Painter类型trySendBlocking(ImageLoadState.Success(result = DrawablePainter(drawable),source = dataSource.toDataSource(),request = request))// Close the channel[Flow]channel.close()// Return true so that the target doesn't receive the drawable// 这里返回true,Target就收不到结果了return true}override fun onLoadFailed(e: GlideException?,model: Any,target: Target<Drawable>,isFirstResource: Boolean): Boolean {// Glide只为Listener派发错误的Exception,因此这里需要缓存一下failException = e// 返回false,允许Target被回调onLoadFailedreturn false}}// Start the image request into the targetrequestManager.load(request).apply { requestBuilder?.invoke(this, size) }.addListener(listener).into(target)// Await the channel being closed and request finishing...awaitClose {// 这里没有调用Glide.clear(),因为clear之后Painter进行绘制的位图可能会被回收,这会报错// See https://github.com/google/accompanist/issues/419}}
}

总体来说状态转换逻辑和以前类似,只不过使用callbackFlow生成数据流后,状态发送显得更加优雅了。

接下来关注rememberLoadPainter的具体实现:

/**
一个通用的 image loading painter,它为要实现的图像加载库提供Loader接口。应用程序通常不应该使用此功能,而更推荐使用在此基础上构建的扩展库,例如Coil和Glide库。
*/
@Composable
fun <R> rememberLoadPainter(loader: Loader<R>,request: R?,shouldRefetchOnSizeChange: ShouldRefetchOnSizeChange,fadeIn: Boolean = false,fadeInDurationMs: Int = LoadPainterDefaults.FadeInTransitionDuration,@DrawableRes previewPlaceholder: Int = 0,
): LoadPainter<R> {val coroutineScope = rememberCoroutineScope()// Our LoadPainter. This invokes the loader as appropriate to display the result.val painter = remember(loader, coroutineScope) {LoadPainter(loader, coroutineScope)}painter.request = requestpainter.shouldRefetchOnSizeChange = shouldRefetchOnSizeChange// 缓存父布局的大小,在计算图片请求的大小时会参考此值painter.rootViewSize = LocalView.current.let { IntSize(it.width, it.height) }// fadeIn动画的ColorFilter// 实现原理和0.6.2版本类似,也是修改了ColorFliter的alpha(透明度),// brightness(亮度),saturation(饱和度),不过这次的ColorFliter由LoadPainter直接进行处理animateFadeInColorFilter(painter = painter,enabled = { result ->// 从 disk/network 才去展示fadeIn动画// 这使我们可以近似地只在“首次加载”时运行动画fadeIn && result is ImageLoadState.Success && result.source != DataSource.MEMORY},durationMs = fadeInDurationMs,)// Our result painter, created from the ImageState with some composition lifecycle// callbacks// 我们的result painter,通过一些composition生命周期的回调从ImageState创建updatePainter(painter, previewPlaceholder)return painter
}

LoaderPainter的实现如下。这里要特别注意RememberObserver这个接口,RememberObserver是一个能够实现对remember行为的观察的接口,如果composition记住或者遗忘的是一个RememberObserver对象,RememberObserver能够收到这个事件,这些事件对LoaderPainter很有用。因为LoaderPainter毕竟并不是一个Compose组件,但是它必须了解它所在的父组件在什么时候离开了屏幕被销毁了(例如高速滑动列表时),这样它能够及时取消对状态流Flow<ImageLoadState>的收集,这是避免发生图片闪烁、错位等问题的关键。

class LoadPainter<R> internal constructor(private val loader: Loader<R>,private val coroutineScope: CoroutineScope,
) : Painter(), RememberObserver {private val paint by lazy(LazyThreadSafetyMode.NONE) { Paint() }internal var painter by mutableStateOf<Painter>(EmptyPainter)// 这个ColorFilter和渐入动画有关internal var transitionColorFilter by mutableStateOf<ColorFilter?>(null)// CoroutineScope for the current requestprivate var requestCoroutineScope: CoroutineScope? = null/*** The current request object.*/var request by mutableStateOf<R?>(null)/*** The root view size.*/internal var rootViewSize by mutableStateOf(IntSize(0, 0))/*** Lambda which will be invoked when the size changes, allowing* optional re-fetching of the image.*/var shouldRefetchOnSizeChange by mutableStateOf(ShouldRefetchOnSizeChange { _, _ -> false })/*** The current [ImageLoadState].* 被观察的ImageLoadState*/var loadState: ImageLoadState by mutableStateOf(ImageLoadState.Empty)private setprivate var alpha: Float by mutableStateOf(1f)private var colorFilter: ColorFilter? by mutableStateOf(null)/*** 执行图像加载请求时要使用的大小*/private var requestSize by mutableStateOf<IntSize?>(null)// Painter内的属性,指定边界大小override val intrinsicSize: Sizeget() = painter.intrinsicSizeoverride fun applyAlpha(alpha: Float): Boolean {this.alpha = alphareturn true}override fun applyColorFilter(colorFilter: ColorFilter?): Boolean {this.colorFilter = colorFilterreturn true}override fun DrawScope.onDraw() {// 根据Canvas的大小确定requestSize,是不是注意到requestSize的确定其实是存在延时的?updateRequestSize(canvasSize = size)// 下面是一些绘制逻辑val transitionColorFilter = transitionColorFilterif (colorFilter != null && transitionColorFilter != null) {// If we have a transition color filter, // and a specified color filter we need to// draw the content in a layer for both to apply.// See https://github.com/google/accompanist/issues/262drawIntoCanvas { canvas ->paint.colorFilter = transitionColorFiltercanvas.saveLayer(bounds = size.toRect(), paint = paint)with(painter) {draw(size, alpha, colorFilter)}canvas.restore()} else {// Otherwise we just draw the content directly, using the filterwith(painter) {draw(size, alpha, colorFilter ?: transitionColorFilter)}}}// RememberObserver的方法// remember运行了计算的lambda但是composition没记住这个对象时回调override fun onAbandoned() {// We've been abandoned from composition, so cancel our request scoperequestCoroutineScope?.cancel()requestCoroutineScope = null}// RememberObserver的方法// composition忘记了这个对象时回调override fun onForgotten() {// We've been forgotten from composition, so cancel our request scope// onAbandoned和onForgotten时都会cancel运行中的协程requestCoroutineScope?.cancel()requestCoroutineScope = null}// RememberObserver的方法// 当composition成功记住此对象时调用。override fun onRemembered() {// Cancel any on-going scope (this shouldn't really happen anyway)// 先取消以前正running的协程requestCoroutineScope?.cancel()// 为当前请求创建新的scope,这允许我们取消作用域,而不影响父作用域的作业。val scope = coroutineScope.coroutineContext.let { context ->CoroutineScope(context + Job(context[Job]))}.also { requestCoroutineScope = it }// 我们已经被记住了,所以可以启动一个协程来观察当前的请求对象和请求大小。// 每当这些值中的任何一个发生变化时,collectLatest块将运行并执行图像加载(任何正在进行的请求都将被取消)。scope.launch {// combine方法如其名,能把两个流合并成一个流// 不过为什么这里要使用snapshotFlow把State转化成流呢?// 因为使用流来监听State变化的最大好处就是collectLatest能够// 取消掉上一次的execute调用并启动新一轮的加载combine(snapshotFlow { request },snapshotFlow { requestSize },transform = { request, size -> request to size }).collectLatest { (request, size) ->execute(request, size)}}// 自动保险。如果没有从onDraw()获得合适的大小,// 我们会将请求大小更新为-1,-1,这将加载原始大小的图像。scope.launch {if (requestSize == null) {// 32ms should be enough time for measure/layout/draw to happen.// 微妙的32毫秒delay(32) if (requestSize == null) {// If we still don't have a request size, resolve the size without// the canvas size// 没获取到Canvas大小,使用原始尺寸updateRequestSize(canvasSize = Size.Zero)}}}}/*** 执行图片加载请求并根据结果更新loadState的方法下面描述的是一些状态转换逻辑,比如如果请求为null,状态就转变为Empty*/private suspend fun execute(request: R?, size: IntSize?) {if (request == null || size == null) {// If we don't have a request, set our state to Empty and returnloadState = ImageLoadState.Emptyreturn}// ...loader.load(request, size).catch { throwable ->when (throwable) {is Error -> throw throwableis IllegalStateException -> throw throwableis IllegalArgumentException -> throw throwableelse -> {emit(ImageLoadState.Error(result = null,throwable = throwable,request = request))}}}.collect { loadState = it }// 上面collect收集了加载的状态,注意,代表图片结果的Painter没被设置到LoadPainter的字段内}private fun updateRequestSize(canvasSize: Size) {requestSize = IntSize(width = when {// If we have a canvas width, use it...canvasSize.width >= 0.5f -> canvasSize.width.roundToInt()// 还记得这个rootViewSize吗?它在rememberLoadPainter函数内被设置rootViewSize.width > 0 -> rootViewSize.widthelse -> -1},height = when {// If we have a canvas height, use it...canvasSize.height >= 0.5f -> canvasSize.height.roundToInt()// Otherwise we fall-back to the root view size as an upper boundrootViewSize.height > 0 -> rootViewSize.heightelse -> -1},)}
}

虽然说LoadPainter确实是实现了RememberObserver,但是,这个回调是怎么被注册的呢?答案藏在习以为常的remember函数中,传入remember的key,或者是calculation得出的值,它们如果是个RememberObserver,则会被插入到RememberManager的队列中,每当“记忆”和“遗忘”事件发生时都会得到通知。

@Composable
inline fun <T> remember(key1: Any?,calculation: @DisallowComposableCalls () -> T
): T {return currentComposer.cache(currentComposer.changed(key1), calculation)
}
// 注意检查key是否有变化的changed函数
@ComposeCompilerApi
override fun changed(value: Any?): Boolean {return if (nextSlot() != value) {updateValue(value)true} else {false}
}@PublishedApi
@OptIn(InternalComposeApi::class)
internal fun updateValue(value: Any?) {// 两个if分支我们都可以看到 rememberManager.remembering()// rememberManager.forgetting()这些调用if (inserting) {writer.update(value)if (value is RememberObserver) {// 注意,判断value是不是RememberObserverrecord { _, _, rememberManager -> rememberManager.remembering(value) }}} else {val groupSlotIndex = reader.groupSlotIndex - 1recordSlotTableOperation(forParent = true) { _, slots, rememberManager ->if (value is RememberObserver) {abandonSet.add(value)rememberManager.remembering(value)}when (val previous = slots.set(groupSlotIndex, value)) {is RememberObserver ->rememberManager.forgetting(previous)is RecomposeScopeImpl -> {val composition = previous.compositionif (composition != null) {previous.composition = nullcomposition.pendingInvalidScopes = true}}}}}
}
// RememberManager是个接口
internal interface RememberManager {/*** The [RememberObserver] is being remembered by a slot in the slot table.*/fun remembering(instance: RememberObserver)/*** The [RememberObserver] is being forgotten by a slot in the slot table.*/fun forgetting(instance: RememberObserver)...
}
// RememberManager的实现类
private class RememberEventDispatcher(private val abandoning: MutableSet<RememberObserver>
) : RememberManager {private val remembering = mutableListOf<RememberObserver>()private val forgetting = mutableListOf<RememberObserver>()private val sideEffects = mutableListOf<() -> Unit>()override fun remembering(instance: RememberObserver) {forgetting.lastIndexOf(instance).let { index ->if (index >= 0) {forgetting.removeAt(index)abandoning.remove(instance)} else {remembering.add(instance)}}}override fun forgetting(instance: RememberObserver) {remembering.lastIndexOf(instance).let { index ->if (index >= 0) {remembering.removeAt(index)abandoning.remove(instance)} else {forgetting.add(instance)}}}fun dispatchRememberObservers() {// 派发forgetting和remembering事件的逻辑if (forgetting.isNotEmpty()) {for (i in forgetting.size - 1 downTo 0) {val instance = forgetting[i]if (instance !in abandoning) {instance.onForgotten()}}}if (remembering.isNotEmpty()) {remembering.fastForEach { instance ->abandoning.remove(instance)instance.onRemembered()}}}// ....
}

我们已经明白LoadPainter到底是怎么管理Loader返回的流结果了,最后一个需要注意的地方在函数updatePainter里,这个调用位于rememberLoadPainter最后,函数实现会根据图片加载State的变化来为LoadPainter设置Painter。不过这不是兜了个圈子吗?似乎也可以在collect更新State的同时把Painter更新一下?

/**
* 允许我们以状态观察当前结果。这个函数允许我们最小化重组范围,这样当loadState改变时,只有这个函数需要重新
* 启动。
*/
@Composable
private fun <R> updatePainter(loadPainter: LoadPainter<R>,@DrawableRes previewPlaceholder: Int = 0,
) {loadPainter.painter = if (LocalInspectionMode.current && previewPlaceholder != 0) {// 如果我们处于检查模式(预览),并且有一个预览占位符,只需使用图像绘制它并返回// 还记得rememberGlidePainter的参数吗?这里就是传入的参数previewPlaceholder的用途// 这个函数令LoadPainter完全忽略了State的变化,只展示静态图片painterResource(previewPlaceholder)} else {// remember在这里看上去像是毫无必要的调用,// 但这允许任何Painter实例接收记忆事件(如果它实现了RememberObserver)。不要移除。remember(loadPainter.loadState) { loadPainter.loadState.painter } ?: EmptyPainter}
}

现在来总结一下0.13.0版本的Glide远程图片扩展的实现思路:

  1. 图片加载:依然是用Target回调获取加载的结果。但是加载状态的返回现在使用流(Flow)来封装,不管是发起加载,异常处理,加载取消都更加优雅直观了。Loader是彻彻底底的生产者,LoadPainter则是消费者。

    LoadPainter并不具有@Composable上下文,作为替代,它实现了RememberObserver来监听控件是否已经离屏销毁。

  2. 图片大小约束:依赖于LoadPainter获取的Canvas的大小。

  3. 渐入动画实现:跟0.6.2版本的思路相似,不过消费ColorFilter的类变成了LoadPainter。

  4. loading占位图、error图等:这些功能直接依赖于具体的图片加载框架的实现,有则有,无则无。0.13.0版本稍微舍去了一些灵活性,不能够像PicassoImage一样直接传入error、loading的Compose内容(控件),不过仍然留有监听图片加载状态的方式,注意,LoadPainter的loadState字段是公开的:

    /*** The current [ImageLoadState].*/
    var loadState: ImageLoadState by mutableStateOf(ImageLoadState.Empty)private set
    

Coil

Accompanist内的Coil集成库最终集成到了Coil内部,成为其扩展,Glide的集成支持则在2021年8月的0.16.0版本被删除。

现在我们简要分析Coil的图片加载逻辑(版本2.0.0-alpha06)。Coil扩展库提供了两种方式来加载网络图片,两种方式正巧就是上面提到的在0.6.2版本与在0.13.0版本的两种实现形式:

// 实现形式1
@Composable
fun AsyncImage(model: Any?,contentDescription: String?,imageLoader: ImageLoader,modifier: Modifier = Modifier,loading: @Composable (AsyncImageScope.(State.Loading) -> Unit)? = null,success: @Composable (AsyncImageScope.(State.Success) -> Unit)? = null,error: @Composable (AsyncImageScope.(State.Error) -> Unit)? = null,alignment: Alignment = Alignment.Center,contentScale: ContentScale = ContentScale.Fit,alpha: Float = DefaultAlpha,colorFilter: ColorFilter? = null,filterQuality: FilterQuality = DefaultFilterQuality,
) {...}
// 实现形式2
@Composable
fun rememberAsyncImagePainter(model: Any?,imageLoader: ImageLoader,filterQuality: FilterQuality = DefaultFilterQuality,
): AsyncImagePainter {...}

我们重点分析第二种形式,即rememberAsyncImagePainter函数,其实该函数的实现逻辑与Glide扩展库比较类似,只在某些细节有所区别:

// 这里不再详细分析源码,挑重要的讲
@Composable
fun rememberAsyncImagePainter(model: Any?,imageLoader: ImageLoader,filterQuality: FilterQuality = DefaultFilterQuality,
): AsyncImagePainter {val request = requestOf(model)requireSupportedData(request.data)// 注意这里,这里要求request的target为nullrequire(request.target == null) { "request.target must be null." }// Dispatchers.Main.immediate是一个有趣的协程调度器,具体效果见类注释val scope = rememberCoroutineScope { Dispatchers.Main.immediate }// AsyncImagePainterval painter = remember(scope) { AsyncImagePainter(scope, request, imageLoader) }painter.request = requestpainter.imageLoader = imageLoaderpainter.filterQuality = filterQuality// 是否处于预览模式painter.isPreview = LocalInspectionMode.current// 这里手动调用了一次onRemembered,onRemembered里有向ImageLoader提交request的逻辑painter.onRemembered() // Invoke this manually so `painter.state` is up to date immediately.// 这里的updatePainter更加复杂,里面有处理fadeIn动画的逻辑updatePainter(painter, request, imageLoader)return painter
}

Dispatchers.Main.immediate比单纯的Dispatchers.Main更加智能,它会减少不必要的调度,当它已经在正确的上下文中,它会立刻执行相应逻辑而无需额外的重新调度。效果类似于下面这样:

suspend fun updateUiElement(val text: String) {/** 假设updateUiElement既会被Main线程调用也会被其他线程调用。* 那么,当updateUiElement是在Main线程被调用的,更新uiElement.text 这段代码会直接运行,而换成Dispatchers.Main的话,它会再进行一次到Main的调度(明显这是赘余的调度)。*/withContext(Dispatchers.Main.immediate) {uiElement.text = text}// Do context-independent logic such as logging
}

接下来我们关注AsyncImagePainter的具体实现:

/*** 异步执行ImageRequest并呈现结果的Painter。*/
class AsyncImagePainter internal constructor(private val parentScope: CoroutineScope,request: ImageRequest,imageLoader: ImageLoader
) : Painter(), RememberObserver {private var rememberScope: CoroutineScope? = null// 图片请求的协程的Jobprivate var requestJob: Job? = nullprivate var drawSize = MutableStateFlow(Size.Zero)private var alpha: Float by mutableStateOf(1f)private var colorFilter: ColorFilter? by mutableStateOf(null)internal var painter: Painter? by mutableStateOf(null)internal var filterQuality = DefaultFilterQualityinternal var isPreview = false/** The current [AsyncImagePainter.State]. */var state: State by mutableStateOf(State.Empty)private setvar request: ImageRequest by mutableStateOf(request)internal setvar imageLoader: ImageLoader by mutableStateOf(imageLoader)internal setoverride val intrinsicSize: Sizeget() = painter?.intrinsicSize ?: Size.Unspecifiedoverride fun DrawScope.onDraw() {// 绘制逻辑非常清爽drawSize.value = size// Draw the current painter.painter?.apply { draw(size, alpha, colorFilter) }}...override fun onRemembered() {// 如果我们处于检查模式(预览),请跳过执行图像请求,并将状态设置为加载。// 对于预览模式的支持if (isPreview) {val request = request.newBuilder().defaults(imageLoader.defaults).build()state = State.Loading(request.placeholder?.toPainter())return}// 与Glide扩展类似,创建了一个子作用域if (rememberScope != null) returnval scope = parentScope + SupervisorJob(parentScope.coroutineContext.job)rememberScope = scope// 观察当前请求+请求大小,并根据需要启动新请求。// Coil天然支持Kotlin协程,无需为生产者额外编写代码scope.launch {snapshotFlow { request }.collect { request ->requestJob?.cancel()requestJob = launch {// execute是挂起函数,返回ImageResultstate = imageLoader.execute(updateRequest(request)).toState()}}}}override fun onForgotten() {rememberScope?.cancel()rememberScope = nullrequestJob?.cancel()requestJob = null}override fun onAbandoned() = onForgotten()/** Update the [request] to work with [AsyncImagePainter]. */private fun updateRequest(request: ImageRequest): ImageRequest {return request.newBuilder().target(onStart = { placeholder ->// 这里获取到placeholder的Painter并更新State为Loadingstate = State.Loading(placeholder?.toPainter())}).apply {if (request.defined.sizeResolver == null) {// Coil内关于设置图片大小的代码// size接受一个SizeResolver,一个含suspend函数的接口// 获取尺寸的函数是挂起函数,非常合理,因为很多时候需要等待控件测量完毕才知道大小size(DrawSizeResolver())}if (request.defined.precision != Precision.EXACT) {precision(Precision.INEXACT)}}.build()}private fun ImageResult.toState() = when (this) {....}private fun Drawable.toPainter() = when (this) {...}/** Suspends until the draw size for this [AsyncImagePainter] is unspecified or positive. */private inner class DrawSizeResolver : SizeResolver {override suspend fun size() = drawSize.mapNotNull { size ->when {// mapNotNull会将drawSize转化为Flow,同时过滤null值,然后挂起函数first()// 将会返回Flow中传送的第一个值size.isUnspecified -> CoilSize.ORIGINALsize.isPositive -> CoilSize(size.width.roundToInt(), size.height.roundToInt())else -> null}}.first()}/*** The current state of the [AsyncImagePainter].* 状态定义*/sealed class State {abstract val painter: Painter?object Empty : State() {override val painter: Painter? get() = null}data class Loading(override val painter: Painter?,) : State()data class Success(override val painter: Painter,val result: SuccessResult,) : State()data class Error(override val painter: Painter?,val result: ErrorResult,) : State()}
}

与Glide扩展库的思路类似,updatePainter函数会监听AsyncImagePainter的加载状态变化,同时更新AsyncImagePainter内的Painter字段。

@Composable
private fun updatePainter(imagePainter: AsyncImagePainter,request: ImageRequest,imageLoader: ImageLoader
) {// This may look like a useless remember, but this allows any painter instances// to receive remember events (if it implements RememberObserver). Do not remove.// 与Glide扩展库一样,允许结果Painter实例接收remember事件(如果它实现了RememberObserver)val state = imagePainter.stateval painter = remember(state) { state.painter }// 如果没有CrossfadeTransition(实现渐入变换)的话,直接设置imagePainter.painter并返回val transition = request.defined.transitionFactory ?: imageLoader.defaults.transitionFactoryif (transition !is CrossfadeTransition.Factory) {imagePainter.painter = painterreturn}// ValueHolder是一个包含static field的数据类,目的是储存state.painter的值,// 避免在state.painter值更新后函数rememberCrossfadePainter重组,// 与rememberUpdatedState有异曲同工之妙,估计是因为rememberUpdatedState没有// 传入key的API(这里要监听request变化),所以这里提供了简易的避免重组的实现val loading = remember(request) { ValueHolder<Painter?>(null) }if (state is State.Loading) loading.value = state.painter// 必须位于Success状态且图片是从网络或磁盘加载的,才允许启动Crossfade,否则返回即可if (state !is State.Success || state.result.dataSource == DataSource.MEMORY_CACHE) {imagePainter.painter = painterreturn}// Set the crossfade painter.// 千呼万唤始出来的CrossfadePainterimagePainter.painter = rememberCrossfadePainter(key = state,start = loading.value,end = painter,scale = request.scale,durationMillis = transition.durationMillis,fadeStart = !state.result.isPlaceholderCached,preferExactIntrinsicSize = transition.preferExactIntrinsicSize)
}
/** A simple mutable value holder that avoids recomposition. */
// 使用静态字段(static)避免重组
private class ValueHolder<T>(@JvmField var value: T)

CrossfadePainter的实现如下:

@Stable
private class CrossfadePainter(private var start: Painter?,private val end: Painter?,private val scale: Scale,private val durationMillis: Int,private val fadeStart: Boolean,private val preferExactIntrinsicSize: Boolean,
) : Painter() {private var invalidateTick by mutableStateOf(0)private var startTimeMillis = -1Lprivate var isDone = falseprivate var maxAlpha: Float by mutableStateOf(1f)private var colorFilter: ColorFilter? by mutableStateOf(null)override val intrinsicSize get() = computeIntrinsicSize()override fun DrawScope.onDraw() {// 如果Alpha变化完毕,直接使用end绘制if (isDone) {drawPainter(end, maxAlpha)return}// Initialize startTimeMillis the first time we're drawn.val uptimeMillis = SystemClock.uptimeMillis()if (startTimeMillis == -1L) {startTimeMillis = uptimeMillis}// Alpha的百分比 = (当前时间 - 开始时间) / 持续时间val percent = (uptimeMillis - startTimeMillis) / durationMillis.toFloat()val endAlpha = percent.coerceIn(0f, 1f) * maxAlphaval startAlpha = if (fadeStart) maxAlpha - endAlpha else maxAlphaisDone = percent >= 1.0// Loading占位图渐出,Success图片结果渐入drawPainter(start, startAlpha)drawPainter(end, endAlpha)if (isDone) {start = null} else {// Increment this value to force the painter to be redrawn.invalidateTick++}}...
}

现在来总结一下Coil远程图片扩展的实现思路:

  1. 图片加载:Coil对协程提供直接的支持,size函数、execute加载函数本身就是挂起函数,因此无需额外的转换逻辑。而AsyncImagePainter则使用Job来控制图片加载协程。

    AsyncImagePainter并不具有@Composable上下文,作为替代,它实现了RememberObserver来监听控件是否已经离屏销毁。

  2. 图片大小约束:依赖于DrawContext的Size。

  3. 渐入动画实现:依赖于DrawScope.onDraw()内的重绘行为,通过对透明度Alpha的百分比计算来实现,令Loading状态的占位图渐出,Success状态的最终结果渐入。

  4. loading占位图、error图等:由Coil提供具体的实现。

根据上述分析我们可以发现,相比于Glide或是Picasso,基于Kotlin协程实现的图片加载库Coil,的确能够很轻松与Jetpack Compose配合工作。

至此对扩展库的分析已经完毕。横向对比来说,无论是对Picasso还是Glide进行扩展,我们都得额外做一些处理,才能够令本身不支持协程的它们在Compose下正常工作。要注意的是,单纯使用自定义的Target把结果返回到某个State,这种简单的做法在列表中可能会遇到严重的性能问题,因为Glide也好,Picasso也好,它们内部实现中取消图片加载以避免图片错位、闪烁的重要参照物就是ImageView,随着列表滑动不断创建的自定义的Target无法被它们识别并进行相应处理。相比之下基于协程的Coil的加载能够变得简单得多,我们只需要利用Job本身就可以控制加载的协程。

最后

我和另外两位小伙伴最近合作构建了一款仿网易云的Android客户端,项目采用MVVM架构,部分界面使用Compose编写,除此之外,项目中还集成了多线程断点续传组件(by Giagor)与基于原生MediaPlayer进行再封装的音乐Service框架(by lanlin-code)

项目地址:https://github.com/giagor/PureJoy

如果项目对你有所帮助,欢迎点赞、Star、收藏~

如何为Compose Image提供网络图片加载支持相关推荐

  1. Android网络图片加载缓存处理库的使用---第三方库学习笔记(五)

    两款比较优秀的开源图片处理库框架:Universal-ImageLoader和Picasso. Universal-ImageLoader 简介: Universal-ImageLoader是目前An ...

  2. Android 进阶:网络图片加载 - Glide篇

    概述: Glide官网 Glide是一个快速高效的Android图片加载库,注重于平滑的滚动.Glide提供了易用的API,高性能.可扩展的图片解码管道(decode pipeline),以及自动的资 ...

  3. 简单的网络图片加载工具类

    简单的网络图片加载工具类 根据图片url网址解生成图片,首先解析图片的流信息,然后通过bitmapfactory工具类生成bitmap图片,设置到图片控件上即可,详情看代码 import androi ...

  4. java一系列图片加载_RxJava系列文章(一) - 网络图片加载水印一般写法

    前言 1. 概述 首先先来实现一个简单的需求,给网络图片添加水印.分别用一般的Java代码 和 RxJava写法来实现,下边先用一般 Java代码实现. 2. 流程图如下: 网络图片添加水印一般写法与 ...

  5. Jetpack Compose——Image使用Coli加载网络图片(包含GIF、SVG)

    Image加载网络图片 首先添加依赖: implementation("io.coil-kt:coil:1.4.0")implementation("io.coil-kt ...

  6. 【Flutter】Image 组件 ( 加载网络图片 | 加载静态图片 | 加载本地图片 | path_provider 插件 )

    文章目录 一.加载网络图片 二.加载静态图片 三.加载本地图片 四.完整代码示例 五.相关资源 一.加载网络图片 参考 [Flutter]Image 组件 ( Image 组件简介 | Image 构 ...

  7. IOS开发笔记 - 基于SDWebImage的网络图片加载处理

    前言: 在IOS下通过URL读一张网络图片并不像Asp.net那样可以直接把图片路径放到图片路径的位置就ok, 而是需要我们通过一段类似流的方式去加载网络图片,接着才能把图片放入图片路径显示. 这里找 ...

  8. Android网络图片加载框架的选择

    前言 Android发展到今天,已经出现了很多优秀的图片缓存函数库,开发人员可以根据实际需求进行选择,传统的图片缓存方案中设置有两级缓存,分别是内存缓存和磁盘缓存.再Facebook推出的Fresco ...

  9. android 下的网络图片加载

    2019独角兽企业重金招聘Python工程师标准>>> Android图片的异步加载,主要原理: 加载图片时先查看缓存中时候存在该图片,如果存在则返回该图片,否则先加载载一个默认的占 ...

最新文章

  1. 服务端异步IO配合协程浅析
  2. request 和response
  3. Deep Learning回顾之LeNet、AlexNet、GoogLeNet、VGG、ResNet
  4. BOOL与bool的区别(bool不是c的关键字,c++中bool也不是int)
  5. Windows 10+Ubuntu 16.04在MBR分区上安装双系统之后没有Windows 10的启动菜单解决方法...
  6. 2019-03-09-算法-进化(买卖股票的最佳时机 II)
  7. mysql找不到sys_解决方法:①MySQL 闪退 ②服务列表里找不到MySQL ③MySQL服务无法启动...
  8. 比尔·盖茨承认犯下 4000 亿美元大错:误给 Google 推出 Android 机会!
  9. onmounted vue3_Vue3.x 生命周期 和 Composition API 核心语法理解
  10. shell脚本获取mysql插入数据自增长id的值
  11. Linux进程间通信-消息队列
  12. mailx配置TSL发送邮件
  13. 杭电多校第九场8月17日补题记录
  14. 关于不定积分和积分上限函数区别的简单讨论
  15. Android开机自启自动轮播图片或自动轮播视频APP
  16. wndDL课程学习笔记
  17. 大白兔奶糖取法(小米公司测试题)——————华清远见
  18. 面试突击:什么是粘包和半包?怎么解决?
  19. 解决Type interface com.kuang.mapper.UserMapper is not known to the MapperRegistry.的问题
  20. 关于高斯克吕格平面直角坐标系

热门文章

  1. 华为手机怎么修改dns服务器,手机修改域名服务器ip地址吗
  2. 研发了晶体管、培养了“八叛逆”、被称为废物天才,硅谷之父传奇的一生
  3. 元器件的非线性与线性
  4. 【垃圾回收器】基于Go实现引用计数法(ReferenceCount)
  5. calibre drc lvs 文件位置
  6. msm8953 + android7.1.2知识总结
  7. git clone 出错 RPC failed;result = 18,http code = 200
  8. 【Python】条件语句、循环语句、pass语句的使用
  9. 2019年“旅行者之选”全球、亚洲、中国最佳目的地 | 周末
  10. 小程序源码:笑话与趣图框架