本系列为小说《逆袭西二旗》的技术讲解,用于详细说明剧情里涉及的开发细节。
如何使用 Drawable
Drawable 是一个通用抽象概念,代表任何可以绘制在屏幕上的内容。它是各种图形内容(如图像、矢量图形和基于形状的元素)的基类。
Drawable 广泛用于 UI 组件中,包括背景、按钮、图标和自定义视图。
Android 提供了不同类型的 Drawable 对象,每种都针对特定的使用场景设计。
面试问题
如何仅使用 Drawable 创建一个动态背景,使其能根据用户交互改变形状和颜色?
BitmapDrawable
BitmapDrawable 用于显示 PNG、JPG 或 GIF 等光栅图像。它支持对 Bitmap 图像进行缩放、平铺和过滤。
<bitmap xmlns:android="http://schemas.android.com/apk/res/android"
android:src="@drawable/sample_image"
android:tileMode="repeat" />
这通常用于在 ImageView 组件中显示图像或作为背景(对于列表的分割线,极为有用)。
VectorDrawable
VectorDrawable 使用 XML 路径表示可缩放矢量图形(类 SVG)。与位图不同,矢量图形在任何分辨率下都能保持质量。
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF0000"
android:pathData="M12,2L15,8H9L12,2Z" />
</vector>
VectorDrawable 非常适合图标、Logo 和可缩放 UI 元素,以避免在不同屏幕密度下出现像素化问题。
NinePatchDrawable
NinePatchDrawable 是一种特殊的 bitmap,允许在调整大小时保留特定区域(如四周的角)。它非常适合创建可拉伸的 UI 组件,如聊天气泡和按钮。
你的 .9 在运行的时会自动转换成 NinePatchDrawable,开发者自己一般不手动创建 NinePatchDrawable。
一个 .9 图片包含额外的 1 像素边框,用于定义可拉伸区域和固定区域。
要创建 .9.png 文件,请使用 Android Studio 的工具定义可拉伸区域。
我工作中发现很多开发不知道 Android Studio 有 .9 制作工具,很多时候都是让 UI 去做的。这样做合理,但是沟通成本太高了,很多 UI 不明白 .9 的工作原理,导致做出来的 .9 反而不满足 UI 的设计。
如图所示,在图片上右键,会出现 Create 9-Patch file 选项。
ShapeDrawable
ShapeDrawable 在 XML 中定义,可用于创建圆角矩形、椭圆或其他简单形状,而无需使用图像。
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#FF5733" />
<corners android:radius="8dp" />
</shape>
ShapeDrawable 可用于按钮、背景和自定义 UI 组件。
因为 ShapeDrawable 不需要 UI 参与制作,所以它对研发来讲,能迅速的完成 UI 实现,同时还能显著的缩小 APK 大小。
LayerDrawable
LayerDrawable 用于将多个 Drawable 组合成一个单层结构,适用于复杂的 UI 背景。
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item>
<shape android:shape="rectangle">
<solid android:color="#000000" />
</shape>
</item>
<item android:drawable="@drawable/icon" android:top="10dp" />
</layer-list>
这可用于创建叠加效果和堆叠视觉元素。
总结
Drawable 类提供了一种灵活的方式来处理 Android 中的不同类型图形。
选择合适的 Drawable 取决于使用场景,例如设计需求、可扩展性和 UI 复杂度。通过利用这些不同的 Drawable 类型,开发者可以在 Android 应用中创建经过优化且视觉吸引人的 UI 组件。
如何高效处理大型 Bitmap?
Bitmap 是内存中图像的表示形式。它存储像素数据,通常用于在屏幕上渲染图像,无论这些图像来自资源、文件还是远程源。
由于 Bitmap 对象会保存大量像素数据,尤其是高分辨率图像,处理不当很容易导致内存耗尽和 OutOfMemoryError。
面试问题
将大型 Bitmap 加载到内存中有哪些风险?
如何避免这些风险?
大型 Bitmap 的问题
许多图像(例如来自相机或从互联网下载的图像)比显示它们的 UI 组件所需的尺寸大得多。不必要地加载这些全分辨率图像会:
- 消耗过多内存
- 带来性能开销
- 导致应用因内存压力而崩溃
不分配内存读取 Bitmap 尺寸
在加载 Bitmap 之前,检查其尺寸以决定是否需要完整加载非常重要。BitmapFactory.Options 类允许你设置 inJustDecodeBounds = true,这样可以在不分配内存存储像素数据的情况下解码图像元数据:
val options = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeResource(resources, R.id.myimage, options)
val imageWidth = options.outWidth
val imageHeight = options.outHeight
val imageType = options.outMimeType
inJustDecodeBounds 这个参数的意思就是仅解析图片的元数据(如宽高、MIME 类型等),而不实际加载像素数据到内存中。
这一步有助于评估图像尺寸是否符合你的显示需求,从而避免不必要的内存分配。
使用采样加载缩小的 Bitmap
一旦知道了尺寸,你可以使用 inSampleSize 选项将 Bitmap 缩小到适合目标尺寸。这会通过以 2、4 等因子(一般给 2 的 N 次方)对图像进行下采样来减少内存使用。例如,一个 2048×1536 的图像在 inSampleSize = 4 时会被加载为 512×384 的 Bitmap:
fun calculateInSampleSize(options: BitmapFactory.Options, reqWidth: Int, reqHeight: Int): Int {
var inSampleSize = 1
val (height, width) = options.run { outHeight to outWidth }
if (height > reqHeight || width > reqWidth) {
val halfHeight = height / 2
val halfWidth = width / 2
while (halfHeight / inSampleSize >= reqHeight && halfWidth / inSampleSize >= reqWidth) {
inSampleSize *= 2
}
}
return inSampleSize
}
这有助于在图像质量和内存效率之间取得平衡。
完整解码流程
使用 calculateInSampleSize,你可以分两步解码 Bitmap:
- 仅解码边界。
- 设置计算出的
inSampleSize并解码缩放后的 Bitmap。
fun decodeSampledBitmapFromResource(
res: Resources,
resId: Int,
reqWidth: Int,
reqHeight: Int
): Bitmap {
return BitmapFactory.Options().run {
inJustDecodeBounds = true
BitmapFactory.decodeResource(res, resId, this)
inSampleSize = calculateInSampleSize(this, reqWidth, reqHeight)
inJustDecodeBounds = false
BitmapFactory.decodeResource(res, resId, this)
}
}
要在 ImageView 中使用它,只需调用:
imageview.setImageBitmap(
decodeSampledBitmapFromResource(resources, R.id.myimage, 100, 100)
)
这种方法确保你的 UI 获得尺寸合适的图像,同时优化了内存使用。
总结
Bitmap 是 Android 中内存密集型的图像表示形式。为避免性能问题和崩溃,首先使用 inJustDecodeBounds 检查图像尺寸,然后使用 inSampleSize 对大型 Bitmap 进行下采样,只加载需要的部分,最后使用两步策略进行高效解码和缩放。
这个过程对于在内存受限的设备上构建处理大量图像的稳健应用至关重要。
进阶:为大型 Bitmap 实现缓存
高效管理大型 Bitmap 对于构建流畅、内存安全的 Android 应用至关重要——尤其是在处理图像列表、网格或轮播时。
Android 提供了两种有效的策略:使用 LruCache 进行内存缓存,以及使用 DiskLruCache 进行基于磁盘的缓存。
大名鼎鼎的 Glide 广泛使用了 LruCache 和 DiskLruCache 相关技术,不过其实现比直接使用这两个类更复杂、更模块化,并且做了大量优化。
先来看看如何使用 LruCache 进行内存缓存。
LruCache 是 Bitmap 的首选内存缓存解决方案。它会保留对最近使用项的强引用,并在内存紧张时自动回收最少使用的项。工作原理如下:
object LruCacheManager {
val maxMemory = (Runtime.getRuntime().maxMemory() / 1024).toInt()
val cacheSize = maxMemory / 8
val memoryCache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, bitmap: Bitmap): Int {
return bitmap.byteCount / 1024
}
}
}
分配约 1/8 的可用内存以确保安全。此设置可快速访问图像并避免重复解码。使用方式如下:
fun loadBitmap(imageId: Int, imageView: ImageView) {
val key = imageId.toString()
LruCacheManager.memoryCache.get(key)?.let {
imageView.setImageBitmap(it)
} ?: run {
imageView.setImageResource(R.drawable.image_placeholder)
val workRequest = OneTimeWorkRequestBuilder<BitmapDecodeWorker>()
.setInputData(workDataOf("imageId" to imageId))
.build()
WorkManager.getInstance(context).enqueue(workRequest)
}
}
在 Worker 中实现如下:
class BitmapDecodeWorker(
context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
val imageId = inputData.getInt("imageId", -1)
if (imageId == -1) return Result.failure()
val bitmap = decodeSampledBitmapFromResource(
applicationContext.resources,
imageId,
reqWidth = 100,
reqHeight = 100
)
bitmap?.let {
LruCacheManager.memoryCache.put(imageId.toString(), it)
return Result.success()
}
return Result.failure()
}
}
接下来,使用 DiskLruCache 进行磁盘缓存。
在 Android 中,内存是有限且易失的。为确保 Bitmap 在应用会话之间持久化并避免重新计算,你可以使用 DiskLruCache 库将 Bitmap 存储在磁盘上。
这对于资源密集型图像或处理可滚动图像列表特别有用。
首先,编写 DiskCacheManager,它包装 DiskLruCache,提供安全的哈希和 I/O 逻辑来持久化解码后的 Bitmap:
class DiskCacheManager(val context: Context) {
private val cachePath = context.cacheDir.path + File.separator + "images"
private val cacheFile = File(cachePath)
private val diskLruCache = DiskLruCache.open(cacheFile, 1, 1, 10*1024*1024 /* 10 megabytes */)
fun filenameForKey(key: String): String {
return MessageDigest
.getInstance("SHA-1")
.digest(key.toByteArray())
.joinToString(separator = "", transform = { Integer.toHexString(0xFF and it.toInt()) })
}
fun get(key: String): Bitmap? {
try {
val filename = filenameForKey(key)
val inputStream = diskLruCache.get(filename).getInputStream(0)
return BitmapFactory.decodeStream(inputStream)
} catch (e: Exception) {
return null
}
}
fun set(key: String, bitmap: Bitmap) {
val filename = filenameForKey(key)
val snapshot = diskLruCache.get(filename)
if (snapshot == null) {
val editor = diskLruCache.edit(filename)
val outputStream = editor.newOutputStream(0)
bitmap.compress(Bitmap.CompressFormat.JPEG, 100, outputStream)
editor.commit()
outputStream.close()
}
snapshot?.getInputStream(0)?.close()
}
}
这个类确保:
- 基于 SHA-1 的安全文件名生成
- 安全的 I/O 操作
- 避免重复写入
接下来,使用 WorkManager 的 CoroutineWorker 在主线程之外执行磁盘缓存,安全地结合内存和磁盘策略:
public class BitmapWorker(
private val context: Context,
workerParams: WorkerParameters
) : CoroutineWorker(context, workerParams) {
override suspend fun doWork(): Result {
val key = inputData.getString("imageKey") ?: return Result.failure()
val resId = inputData.getInt("resId", -1)
if (resId == -1) return Result.failure()
// 先尝试磁盘缓存
val bitmapFromDisk = getBitmapFromDiskCache(key)
if (bitmapFromDisk != null) {
LruCacheManager.memoryCache.put(key, bitmapFromDisk)
return Result.success()
}
// 如果未找到则解码并缓存
val bitmap = decodeSampledBitmapFromResource(
applicationContext.resources,
resId,
reqWidth = 100,
reqHeight = 100
)
// 你必须将此移到 Application 类中,或者从依赖注入获取实例。
// 如果每次 Worker 执行时都创建实例,磁盘缓存就失去了意义。
val diskCacheManager = DiskCacheManager(context)
try {
addBitmapToCache(diskCacheManager, key, bitmap)
return Result.success()
} catch (e: Exception) {
return Result.failure()
}
}
private fun addBitmapToCache(diskCacheManager: DiskCacheManager, key: String, bitmap: Bitmap) {
if (LruCacheManager.memoryCache.get(key) == null) {
LruCacheManager.memoryCache.put(key, bitmap)
}
if (diskCacheManager.get(key) != null) {
diskCacheManager.set(key, bitmap)
}
}
}
这个 Worker:
- 尽可能从磁盘读取
- 如果未找到则回退到解码
- 将结果存储到内存和磁盘缓存中
- 在主线程之外安全运行
总之。
要在 Android 中高效缓存大型 Bitmap,请使用 LruCache 进行快速的最近访问内存缓存,并使用 DiskLruCache 在应用会话之外持久化 Bitmap。
结合这两种策略,并在配置更改时保留内存缓存,以获得无缝体验。
通过使用 WorkManager 进行适当的初始化和后台工作,这种混合功能可以提高处理大型 Bitmap 时的应用性能和用户体验。
使用 WorkManager 是可选项,你可以使用协程,后台现成来完成这工作。不过各位有兴趣可以看看 WorkManager,在运行长时间的,不确定的后台任务时,WorkManager 是非常棒的选择。
如何实现动画
动画通过创建平滑过渡、吸引对变化的注意力并提供视觉反馈来增强用户体验。Android 提供了多种实现动画的机制,从基本的属性变化到复杂的布局动画。
面试问题
如何实现按钮在点击时平滑地展开和收缩,并确保其高效运行?
何时使用 MotionLayout 而不是传统的 View 动画?它有哪些优势?
View 属性动画
在 API 级别 11 中引入,View 属性动画允许你为 View 对象的属性(如 alpha、translationX、translationY、rotation 和 scaleX)设置动画。
这种方法非常适合简单的变换。
val view: View = findViewById(R.id.my_view)
view.animate()
.alpha(0.5f)
.translationX(100f)
.setDuration(500)
.start()
ObjectAnimator
ObjectAnimator 允许你为任何具有 setter 方法的对象的属性设置动画,而不仅仅是 View 对象(有 setter 方法皆可,可以是一个普通类)。它为动画自定义属性提供了更大的灵活性。
val animator = ObjectAnimator.ofFloat(view, "translationX", 0f, 300f)
animator.duration = 500
animator.start()
AnimatorSet
AnimatorSet 可以组合多个动画,使其按顺序或同时运行,非常适合协调复杂的动画。
val fadeAnimator = ObjectAnimator.ofFloat(view, "alpha", 1f, 0f)
val moveAnimator = ObjectAnimator.ofFloat(view, "translationX", 0f, 200f)
val animatorSet = AnimatorSet()
animatorSet.playSequentially(fadeAnimator, moveAnimator)
animatorSet.duration = 1000
animatorSet.start()
ValueAnimator
ValueAnimator 提供了在任意值之间进行动画的功能,可高度定制且灵活。
通过使用插值器控制动画进度,它可以适应各种使用场景,例如为宽度、高度、alpha 或任何其他属性设置动画。
这使其成为为特定需求定制精确且动态动画的绝佳选择。
val valueAnimator = ValueAnimator.ofInt(0, 100)
valueAnimator.duration = 500
valueAnimator.addUpdateListener { animation ->
val animatedValue = animation.animatedValue as Int
binding.progressbar.updateLayoutParams {
width = (screenSize / 100) * animatedValue.toInt()
}
}
valueAnimator.start()
基于 XML 的 View 动画
基于 XML 的动画在资源文件中定义,以实现简洁性和可重用性。这些动画可以影响位置、缩放、旋转和透明度。
下面是一个 XML 示例:res/anim/slide_in.xml:
<translate xmlns:android="http://schemas.android.com/apk/res/android"
android:fromXDelta="-100%"
android:toXDelta="0%"
android:duration="500" />
使用方法也非常简单:
val animation = AnimationUtils.loadAnimation(this, R.anim.slide_in)
view.startAnimation(animation)
MotionLayout
MotionLayout 是 Android 中创建复杂运动和布局动画的强大工具。它构建在 ConstraintLayout 之上,允许你使用 XML 定义动画和状态之间的过渡。
XML 示例:res/layout/motion_scene.xml:
<MotionScene xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<Transition
app:constraintSetStart="@id/start"
app:constraintSetEnd="@id/end"
app:duration="500">
<OnSwipe
app:touchAnchorId="@id/box"
app:touchAnchorSide="top"
app:dragDirection="dragDown" />
</Transition>
</MotionScene>
在布局中使用:
<androidx.constraintlayout.motion.widget.MotionLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutDescription="@xml/motion_scene">
<View
android:id="@+id/box"
android:layout_width="100dp"
android:layout_height="100dp"
android:background="@color/blue" />
</androidx.constraintlayout.motion.widget.MotionLayout>
MotionLayout 非常适合创建复杂的动画,可精确控制过渡和状态。
Drawable 动画
Drawable 动画涉及使用 AnimationDrawable 进行逐帧过渡,适合创建简单的动画,如加载 spinner。
XML 示例:res/drawable/animation_list.xml:
<animation-list xmlns:android="http://schemas.android.com/apk/res/android" android:oneshot="false">
<item android:drawable="@drawable/frame1" android:duration="100" />
<item android:drawable="@drawable/frame2" android:duration="100" />
</animation-list>
使用方法如下:
val animationDrawable = imageView.background as AnimationDrawable
animationDrawable.start()
注意,只设置 background 为 AnimationDrawable 是不会工作的,一定不要忘了 start。
基于物理的动画
基于物理的动画模拟现实世界的动力学效果。Android 提供了 SpringAnimation 和 FlingAnimation API,用于创建自然且动态的运动效果。
val springAnimation = SpringAnimation(view, DynamicAnimation.TRANSLATION_Y, 0f)
springAnimation.spring.stiffness = SpringForce.STIFFNESS_LOW
springAnimation.spring.dampingRatio = SpringForce.DAMPING_RATIO_HIGH_BOUNCY
springAnimation.start()
总结
Android 提供了一系列实现动画的工具,从简单的属性变化到复杂的状态驱动过渡。
对于缩放或平移等简单变换,View 属性动画 和 ObjectAnimator 是有效的选择。
对于更复杂的场景,AnimatorSet 可以协调多个动画,而 ValueAnimator 提供了灵活的方式来为任意值设置动画。
不过这种传统的动画有个开发上的痛点,就是取消!
传动的动画把控取消的合适位置是很难的,所以如果你使用 Compose 进行开发,就直接使用 Compose 的动画,忘记这些传统动画吧!
进阶:插值器是如何工作的
插值器通过修改动画值随时间变化的速率来定义动画的进度。它控制动画的加速、减速或匀速运动,使动画看起来更自然或更具视觉吸引力。
插值器用于定义动画在起始值和结束值之间的行为。例如,你可以让动画开始时缓慢,然后加速,最后在停止前减速。 这提供了对动画执行方式的灵活性和控制,超越了线性进度。
Android 提供了几种预定义的插值器,你可以根据所需效果选择:
LinearInterpolator:以恒定速率动画,没有加速或减速。AccelerateInterpolator:开始时缓慢,然后逐渐加速。DecelerateInterpolator:开始时快速,然后向结束时减速。AccelerateDecelerateInterpolator:结合了加速和减速,以获得平滑效果。BounceInterpolator:使动画看起来像在弹跳,模拟物理弹跳效果。OvershootInterpolator:动画会超出最终值,然后再回退到最终值。
你可以将插值器应用于任何动画对象,如 ObjectAnimator、ValueAnimator 或 ViewPropertyAnimator。
插值器通过 setInterpolator() 方法设置。
下面举例,将插值器应用于 ObjectAnimator:
val animator = ObjectAnimator.ofFloat(view, "translationX", 0f, 500f)
animator.duration = 1000
animator.interpolator = OvershootInterpolator()
animator.start()
在这个示例中,OvershootInterpolator 会让视图在最终位置上过度动画,然后再回到最终位置,创造出动态且吸引人的效果。
你还可以通过扩展 Interpolator 接口并覆盖 getInterpolation() 方法来创建自己的插值器。这允许你完全自定义动画的时间曲线。
class CustomInterpolator : Interpolator {
override fun getInterpolation(input: Float): Float {
// 自定义动画时间逻辑
return input * input
}
}
// 使用自定义插值器
animator.interpolator = CustomInterpolator()
这个自定义插值器会让动画呈二次方进度,开始缓慢,然后随时间加速。
总之。
插值器通过控制动画的时间和进度来增强 Android 动画,提供了创建更视觉上吸引人且逼真效果的方法。
Android 提供了几种内置插值器用于常见场景,你也可以定义自定义插值器来实现独特的行为。
通过有效利用插值器,你可以显著提升用户体验,实现平滑自然的动画。