From 5e65b7fe4dabba38b62e52972518df60bbc12e63 Mon Sep 17 00:00:00 2001 From: AllenTom Date: Thu, 22 Aug 2024 23:43:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9B=B4=E6=96=B0=E5=9B=BE=E7=89=87=E7=BC=93?= =?UTF-8?q?=E5=AD=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../aiosman/riderpro/data/AccountService.kt | 10 +- .../aiosman/riderpro/data/CommentService.kt | 6 +- .../aiosman/riderpro/data/MomentService.kt | 11 +- .../aiosman/riderpro/model/MomentEntity.kt | 5 +- .../riderpro/ui/composables/BlurHash.kt | 81 +++++++ .../riderpro/ui/index/tabs/moment/Moment.kt | 15 +- .../aiosman/riderpro/utils/BlurHashDecoder.kt | 218 ++++++++++++++++++ .../java/com/aiosman/riderpro/utils/Utils.kt | 17 +- 8 files changed, 342 insertions(+), 21 deletions(-) create mode 100644 app/src/main/java/com/aiosman/riderpro/ui/composables/BlurHash.kt create mode 100644 app/src/main/java/com/aiosman/riderpro/utils/BlurHashDecoder.kt diff --git a/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt b/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt index 5cd853c..140ebbc 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/AccountService.kt @@ -55,7 +55,7 @@ data class AccountProfile( followerCount = followerCount, followingCount = followingCount, nickName = nickname, - avatar = ApiClient.BASE_SERVER + avatar + "?token=${AppStore.token}", + avatar = "${ApiClient.BASE_SERVER}$avatar", bio = "", country = "Worldwide", isFollowing = isFollowing @@ -86,8 +86,8 @@ data class NoticePost( textContent = textContent, images = images.map { it.copy( - url = ApiClient.BASE_SERVER + it.url + "?token=${AppStore.token}", - thumbnail = ApiClient.BASE_SERVER + it.thumbnail + "?token=${AppStore.token}", + url = "${ApiClient.BASE_SERVER}${it.url}", + thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}", ) }, time = ApiClient.dateFromApiString(time) @@ -113,7 +113,7 @@ data class NoticeUser( return NoticeUserEntity( id = id, nickName = nickName, - avatar = ApiClient.BASE_SERVER + avatar + "?token=${AppStore.token}", + avatar = "${ApiClient.BASE_SERVER}$avatar", ) } } @@ -259,7 +259,7 @@ class FollowItemPagingSource( LoadResult.Page( data = followListContainer.list.map { it.copy( - avatar = ApiClient.BASE_SERVER + it.avatar + "?token=${AppStore.token}", + avatar = "${ApiClient.BASE_SERVER}${it.avatar}", ) }, prevKey = if (currentPage == 1) null else currentPage - 1, diff --git a/app/src/main/java/com/aiosman/riderpro/data/CommentService.kt b/app/src/main/java/com/aiosman/riderpro/data/CommentService.kt index c311e35..6eb080c 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/CommentService.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/CommentService.kt @@ -55,7 +55,7 @@ data class Comment( likes = likeCount, replies = emptyList(), postId = postId, - avatar = ApiClient.BASE_SERVER + user.avatar + "?token=${AppStore.token}", + avatar = "${ApiClient.BASE_SERVER}${user.avatar}", author = user.id, liked = isLiked, unread = isUnread, @@ -63,8 +63,8 @@ data class Comment( it.copy( images = it.images.map { it.copy( - url = ApiClient.BASE_SERVER + it.url + "?token=${AppStore.token}", - thumbnail = ApiClient.BASE_SERVER + it.thumbnail + "?token=${AppStore.token}" + url = "${ApiClient.BASE_SERVER}${it.url}", + thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}" ) } ) diff --git a/app/src/main/java/com/aiosman/riderpro/data/MomentService.kt b/app/src/main/java/com/aiosman/riderpro/data/MomentService.kt index d59b2d2..126ef22 100644 --- a/app/src/main/java/com/aiosman/riderpro/data/MomentService.kt +++ b/app/src/main/java/com/aiosman/riderpro/data/MomentService.kt @@ -44,10 +44,10 @@ data class Moment( val time: String ) { fun toMomentItem(): MomentEntity { - val avatar = ApiClient.BASE_SERVER + user.avatar + "?token=${AppStore.token}" + val avatar = "${ApiClient.BASE_SERVER}${user.avatar}" return MomentEntity( id = id.toInt(), - avatar = ApiClient.BASE_SERVER + user.avatar + "?token=${AppStore.token}", + avatar = "${ApiClient.BASE_SERVER}${user.avatar}", nickname = user.nickName, location = "Worldwide", time = time, @@ -60,9 +60,10 @@ data class Moment( favoriteCount = favoriteCount.toInt(), images = images.map { MomentImageEntity( - url = ApiClient.BASE_SERVER + it.url + "?token=${AppStore.token}", - thumbnail = ApiClient.BASE_SERVER + it.thumbnail + "?token=${AppStore.token}", - id = it.id + url = "${ApiClient.BASE_SERVER}${it.url}", + thumbnail = "${ApiClient.BASE_SERVER}${it.thumbnail}", + id = it.id, + blurHash = it.blurHash ) }, authorId = user.id.toInt(), diff --git a/app/src/main/java/com/aiosman/riderpro/model/MomentEntity.kt b/app/src/main/java/com/aiosman/riderpro/model/MomentEntity.kt index 55ec89f..074c061 100644 --- a/app/src/main/java/com/aiosman/riderpro/model/MomentEntity.kt +++ b/app/src/main/java/com/aiosman/riderpro/model/MomentEntity.kt @@ -1,11 +1,14 @@ package com.aiosman.riderpro.model import androidx.annotation.DrawableRes + data class MomentImageEntity( val id: Long, val url: String, - val thumbnail: String + val thumbnail: String, + val blurHash: String? = null ) + data class MomentEntity( val id: Int, val avatar: String, diff --git a/app/src/main/java/com/aiosman/riderpro/ui/composables/BlurHash.kt b/app/src/main/java/com/aiosman/riderpro/ui/composables/BlurHash.kt new file mode 100644 index 0000000..70a01b2 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/ui/composables/BlurHash.kt @@ -0,0 +1,81 @@ +package com.aiosman.riderpro.ui.composables + +import android.content.Context +import android.graphics.drawable.BitmapDrawable +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.core.graphics.drawable.toDrawable +import coil.ImageLoader +import coil.annotation.ExperimentalCoilApi +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.aiosman.riderpro.utils.BlurHashDecoder +import com.aiosman.riderpro.utils.Utils.getImageLoader + +private const val DEFAULT_HASHED_BITMAP_WIDTH = 4 +private const val DEFAULT_HASHED_BITMAP_HEIGHT = 3 + +/** + * This function is used to load an image asynchronously and blur it using BlurHash. + * @param imageUrl The URL of the image to be loaded. + * @param modifier The modifier to be applied to the image. + * @param imageModifier The modifier to be applied to the image. + * @param contentDescription The content description to be applied to the image. + * @param contentScale The content scale to be applied to the image. + * @param isCrossFadeRequired Whether cross-fade is required or not. + * @param onImageLoadSuccess The callback to be called when the image is loaded successfully. + * @param onImageLoadFailure The callback to be called when the image is failed to load. + * @see AsyncImage + */ +@Suppress("LongParameterList") +@ExperimentalCoilApi +@Composable +fun AsyncBlurImage( + imageUrl: String, + blurHash: String, + modifier: Modifier = Modifier, + imageModifier: Modifier? = null, + contentDescription: String? = null, + contentScale: ContentScale = ContentScale.Fit, + isCrossFadeRequired: Boolean = false, + onImageLoadSuccess: () -> Unit = {}, + onImageLoadFailure: () -> Unit = {} +) { + val context = LocalContext.current + val resources = context.resources + val imageLoader = getImageLoader(context) + + val blurBitmap by remember(blurHash) { + mutableStateOf( + BlurHashDecoder.decode( + blurHash = blurHash, + width = DEFAULT_HASHED_BITMAP_WIDTH, + height = DEFAULT_HASHED_BITMAP_HEIGHT + ) + ) + } + + AsyncImage( + modifier = imageModifier ?: modifier, + model = ImageRequest.Builder(context) + .data(imageUrl) + .crossfade(isCrossFadeRequired) + .placeholder( + blurBitmap?.toDrawable(resources) + ) + .fallback(blurBitmap?.toDrawable(resources)) + .build(), + contentDescription = contentDescription, + contentScale = contentScale, + onSuccess = { onImageLoadSuccess() }, + onError = { onImageLoadFailure() }, + imageLoader = imageLoader + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/moment/Moment.kt b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/moment/Moment.kt index 815a87a..61c7500 100644 --- a/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/moment/Moment.kt +++ b/app/src/main/java/com/aiosman/riderpro/ui/index/tabs/moment/Moment.kt @@ -59,7 +59,6 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.paging.LoadState import androidx.paging.compose.collectAsLazyPagingItems -import coil.compose.AsyncImage import com.aiosman.riderpro.LocalAnimatedContentScope import com.aiosman.riderpro.LocalNavController import com.aiosman.riderpro.LocalSharedTransitionScope @@ -71,9 +70,9 @@ import com.aiosman.riderpro.ui.comment.CommentModalContent import com.aiosman.riderpro.ui.composables.AnimatedCounter import com.aiosman.riderpro.ui.composables.AnimatedFavouriteIcon import com.aiosman.riderpro.ui.composables.AnimatedLikeIcon +import com.aiosman.riderpro.ui.composables.AsyncBlurImage import com.aiosman.riderpro.ui.composables.CustomAsyncImage import com.aiosman.riderpro.ui.composables.RelPostCard -import com.aiosman.riderpro.ui.imageviewer.ImageViewerViewModel import com.aiosman.riderpro.ui.modifiers.noRippleClickable import com.aiosman.riderpro.ui.post.NewPostViewModel import com.google.accompanist.systemuicontroller.rememberSystemUiController @@ -353,6 +352,18 @@ fun PostImageView( ) { page -> val image = images[page] with(sharedTransitionScope) { +// AsyncBlurImage( +// imageUrl = image.url, +// blurHash = image.blurHash ?: "", +// contentDescription = "Image", +// contentScale = ContentScale.Crop, +// modifier = Modifier +// .sharedElement( +// rememberSharedContentState(key = image), +// animatedVisibilityScope = animatedVisibilityScope +// ) +// .fillMaxSize() +// ) CustomAsyncImage( context, image.thumbnail, diff --git a/app/src/main/java/com/aiosman/riderpro/utils/BlurHashDecoder.kt b/app/src/main/java/com/aiosman/riderpro/utils/BlurHashDecoder.kt new file mode 100644 index 0000000..13b7613 --- /dev/null +++ b/app/src/main/java/com/aiosman/riderpro/utils/BlurHashDecoder.kt @@ -0,0 +1,218 @@ +package com.aiosman.riderpro.utils + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.collection.SparseArrayCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.math.cos +import kotlin.math.pow +import kotlin.math.withSign + +internal object BlurHashDecoder { + + // cache Math.cos() calculations to improve performance. + // The number of calculations can be huge for many bitmaps: width * height * numCompX * numCompY * 2 * nBitmaps + // the cache is enabled by default, it is recommended to disable it only when just a few images are displayed + private val cacheCosinesX = SparseArrayCompat() + private val cacheCosinesY = SparseArrayCompat() + + /** + * Clear calculations stored in memory cache. + * The cache is not big, but will increase when many image sizes are used, + * if the app needs memory it is recommended to clear it. + */ + private fun clearCache() { + cacheCosinesX.clear() + cacheCosinesY.clear() + } + + /** + * Decode a blur hash into a new bitmap. + * + * @param useCache use in memory cache for the calculated math, reused by images with same size. + * if the cache does not exist yet it will be created and populated with new calculations. + * By default it is true. + */ + @Suppress("ReturnCount") + internal fun decode( + blurHash: String?, + width: Int, + height: Int, + punch: Float = 1f, + useCache: Boolean = true + ): Bitmap? { + if (blurHash == null || blurHash.length < 6) { + return null + } + val numCompEnc = decode83(blurHash, 0, 1) + val numCompX = (numCompEnc % 9) + 1 + val numCompY = (numCompEnc / 9) + 1 + if (blurHash.length != 4 + 2 * numCompX * numCompY) { + return null + } + val maxAcEnc = decode83(blurHash, 1, 2) + val maxAc = (maxAcEnc + 1) / 166f + val colors = Array(numCompX * numCompY) { i -> + if (i == 0) { + val colorEnc = decode83(blurHash, 2, 6) + decodeDc(colorEnc) + } else { + val from = 4 + i * 2 + val colorEnc = decode83(blurHash, from, from + 2) + decodeAc(colorEnc, maxAc * punch) + } + } + return composeBitmap(width, height, numCompX, numCompY, colors, useCache) + } + + private fun decode83(str: String, from: Int = 0, to: Int = str.length): Int { + var result = 0 + for (i in from until to) { + val index = charMap[str[i]] ?: -1 + if (index != -1) { + result = result * 83 + index + } + } + return result + } + + private fun decodeDc(colorEnc: Int): FloatArray { + val r = colorEnc shr 16 + val g = (colorEnc shr 8) and 255 + val b = colorEnc and 255 + return floatArrayOf(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b)) + } + + private fun srgbToLinear(colorEnc: Int): Float { + val v = colorEnc / 255f + return if (v <= 0.04045f) { + (v / 12.92f) + } else { + ((v + 0.055f) / 1.055f).pow(2.4f) + } + } + + private fun decodeAc(value: Int, maxAc: Float): FloatArray { + val r = value / (19 * 19) + val g = (value / 19) % 19 + val b = value % 19 + return floatArrayOf( + signedPow2((r - 9) / 9.0f) * maxAc, + signedPow2((g - 9) / 9.0f) * maxAc, + signedPow2((b - 9) / 9.0f) * maxAc + ) + } + + private fun signedPow2(value: Float) = value.pow(2f).withSign(value) + + private fun composeBitmap( + width: Int, + height: Int, + numCompX: Int, + numCompY: Int, + colors: Array, + useCache: Boolean + ): Bitmap { + // use an array for better performance when writing pixel colors + val imageArray = IntArray(width * height) + val calculateCosX = !useCache || !cacheCosinesX.containsKey(width * numCompX) + val cosinesX = getArrayForCosinesX(calculateCosX, width, numCompX) + val calculateCosY = !useCache || !cacheCosinesY.containsKey(height * numCompY) + val cosinesY = getArrayForCosinesY(calculateCosY, height, numCompY) + runBlocking { + CoroutineScope(SupervisorJob() + Dispatchers.IO).launch { + val tasks = ArrayList>() + tasks.add( + async { + for (y in 0 until height) { + for (x in 0 until width) { + var r = 0f + var g = 0f + var b = 0f + for (j in 0 until numCompY) { + for (i in 0 until numCompX) { + val cosX = + cosinesX.getCos(calculateCosX, i, numCompX, x, width) + val cosY = + cosinesY.getCos(calculateCosY, j, numCompY, y, height) + val basis = (cosX * cosY).toFloat() + val color = colors[j * numCompX + i] + r += color[0] * basis + g += color[1] * basis + b += color[2] * basis + } + } + imageArray[x + width * y] = + Color.rgb(linearToSrgb(r), linearToSrgb(g), linearToSrgb(b)) + } + } + return@async + } + ) + tasks.forEach { it.await() } + }.join() + } + + return Bitmap.createBitmap(imageArray, width, height, Bitmap.Config.ARGB_8888) + } + + private fun getArrayForCosinesY(calculate: Boolean, height: Int, numCompY: Int) = when { + calculate -> { + DoubleArray(height * numCompY).also { + cacheCosinesY.put(height * numCompY, it) + } + } + + else -> { + cacheCosinesY.get(height * numCompY)!! + } + } + + private fun getArrayForCosinesX(calculate: Boolean, width: Int, numCompX: Int) = when { + calculate -> { + DoubleArray(width * numCompX).also { + cacheCosinesX.put(width * numCompX, it) + } + } + + else -> cacheCosinesX.get(width * numCompX)!! + } + + private fun DoubleArray.getCos( + calculate: Boolean, + x: Int, + numComp: Int, + y: Int, + size: Int + ): Double { + if (calculate) { + this[x + numComp * y] = cos(Math.PI * y * x / size) + } + return this[x + numComp * y] + } + + private fun linearToSrgb(value: Float): Int { + val v = value.coerceIn(0f, 1f) + return if (v <= 0.0031308f) { + (v * 12.92f * 255f + 0.5f).toInt() + } else { + ((1.055f * v.pow(1 / 2.4f) - 0.055f) * 255 + 0.5f).toInt() + } + } + + private val charMap = listOf( + '0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F', 'G', + 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', + 'Y', 'Z', 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', + 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', '#', '$', '%', '*', '+', ',', + '-', '.', ':', ';', '=', '?', '@', '[', ']', '^', '_', '{', '|', '}', '~' + ) + .mapIndexed { i, c -> c to i } + .toMap() +} \ No newline at end of file diff --git a/app/src/main/java/com/aiosman/riderpro/utils/Utils.kt b/app/src/main/java/com/aiosman/riderpro/utils/Utils.kt index e44cc90..38899d8 100644 --- a/app/src/main/java/com/aiosman/riderpro/utils/Utils.kt +++ b/app/src/main/java/com/aiosman/riderpro/utils/Utils.kt @@ -2,6 +2,8 @@ package com.aiosman.riderpro.utils import android.content.Context import coil.ImageLoader +import coil.disk.DiskCache +import coil.memory.MemoryCache import coil.request.CachePolicy import com.aiosman.riderpro.data.api.getUnsafeOkHttpClient import java.util.Date @@ -19,11 +21,16 @@ object Utils { val okHttpClient = getUnsafeOkHttpClient() return ImageLoader.Builder(context) .okHttpClient(okHttpClient) - .diskCachePolicy(CachePolicy.ENABLED) - .memoryCachePolicy(CachePolicy.ENABLED) - - .components { - + .memoryCache { + MemoryCache.Builder(context) + .maxSizePercent(0.25) // 设置内存缓存大小为可用内存的 25% + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("image_cache")) + .maxSizePercent(0.02) // 设置磁盘缓存大小为可用存储空间的 2% + .build() }.build() }