Coil - Load hình ảnh trong Android
Coil là gì?
Khi xây dựng ứng dụng Android, bạn thường cần hiển thị hình ảnh từ internet. Ví dụ:
- Avatar của user
- Hình sản phẩm trong app bán hàng
- Banner quảng cáo
- Ảnh trong feed mạng xã hội
Vấn đề: Android không có sẵn cách đơn giản để load ảnh từ URL. Bạn phải tự:
- Download ảnh từ internet
- Decode ảnh thành Bitmap
- Xử lý cache để không download lại
- Hiển thị placeholder khi đang load
- Xử lý lỗi nếu không load được
Giải pháp: Coil (Coroutine Image Loader) - thư viện load ảnh hiện đại cho Android, được viết bằng Kotlin và hỗ trợ tốt Jetpack Compose.
Tại sao chọn Coil?
| Thư viện | Ưu điểm | Nhược điểm |
|---|---|---|
| Coil | Kotlin-first, nhẹ, Compose support | Mới hơn |
| Glide | Phổ biến, nhiều tài liệu | Java-based |
| Picasso | Đơn giản | Ít tính năng |
Bước 1: Thêm Coil vào dự án
1.1. Mở file build.gradle.kts (Module: app)
Thêm dependency vào block dependencies:
dependencies {
// Coil cho Jetpack Compose
implementation("io.coil-kt:coil-compose:2.5.0")
}1.2. Sync Gradle
Click Sync Now hoặc vào File > Sync Project with Gradle Files
1.3. Thêm permission Internet
Mở file app/src/main/AndroidManifest.xml, thêm permission:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Thêm dòng này -->
<uses-permission android:name="android.permission.INTERNET" />
<application ...>
...
</application>
</manifest>Tại sao cần permission INTERNET?
- Android bảo vệ người dùng bằng cách yêu cầu app khai báo các quyền cần sử dụng
- Load ảnh từ URL = truy cập internet = cần permission
Bước 2: Hiển thị ảnh đơn giản
2.1. Composable AsyncImage cơ bản
import coil.compose.AsyncImage
@Composable
fun UserAvatar() {
AsyncImage(
model = "https://picsum.photos/200", // URL của ảnh
contentDescription = "Avatar", // Mô tả cho accessibility
modifier = Modifier.size(100.dp) // Kích thước
)
}Giải thích từng tham số:
model: URL của ảnh cần load (có thể là String, Uri, hoặc ImageRequest)contentDescription: Mô tả ảnh cho người dùng khiếm thị (screen reader sẽ đọc)modifier: Điều chỉnh kích thước, padding, shape…
2.2. Chạy thử
Thêm UserAvatar() vào MainActivity:
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContent {
UserAvatar()
}
}
}Chạy app → Bạn sẽ thấy một ảnh ngẫu nhiên từ Picsum!
Bước 3: Thêm Placeholder và Error handling
Khi load ảnh từ internet, có 3 trạng thái:
- Loading: Đang tải ảnh
- Success: Load thành công
- Error: Lỗi (mất mạng, URL sai, server lỗi…)
import coil.compose.AsyncImage
import coil.compose.SubcomposeAsyncImage
import coil.compose.SubcomposeAsyncImageContent
@Composable
fun UserAvatarWithStates() {
AsyncImage(
model = "https://picsum.photos/200",
contentDescription = "Avatar",
modifier = Modifier
.size(100.dp)
.clip(CircleShape), // Bo tròn ảnh
// Ảnh hiển thị khi ĐANG LOAD
placeholder = painterResource(R.drawable.placeholder),
// Ảnh hiển thị khi LỖI
error = painterResource(R.drawable.error_image),
// Cách scale ảnh
contentScale = ContentScale.Crop
)
}Giải thích contentScale:
ContentScale.Crop: Cắt ảnh để fill khung, giữ tỷ lệContentScale.Fit: Ảnh vừa khung, có thể có khoảng trốngContentScale.FillBounds: Kéo dãn ảnh để fill, có thể méo
Cách tạo placeholder đơn giản
Nếu chưa có ảnh placeholder, dùng Box với màu:
@Composable
fun UserAvatarSimple() {
SubcomposeAsyncImage(
model = "https://picsum.photos/200",
contentDescription = "Avatar",
modifier = Modifier
.size(100.dp)
.clip(CircleShape)
) {
val state = painter.state
when (state) {
is AsyncImagePainter.State.Loading -> {
// Hiển thị khi đang load
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.LightGray),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator(modifier = Modifier.size(24.dp))
}
}
is AsyncImagePainter.State.Error -> {
// Hiển thị khi lỗi
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Red.copy(alpha = 0.3f)),
contentAlignment = Alignment.Center
) {
Icon(
Icons.Default.Warning,
contentDescription = "Error",
tint = Color.White
)
}
}
else -> {
// Hiển thị ảnh khi load thành công
SubcomposeAsyncImageContent()
}
}
}
}Bước 4: Ví dụ thực tế - Danh sách sản phẩm
4.1. Tạo data class
data class Product(
val id: Int,
val name: String,
val price: Double,
val imageUrl: String
)
// Dữ liệu mẫu
val sampleProducts = listOf(
Product(1, "iPhone 15", 999.0, "https://picsum.photos/seed/1/300/300"),
Product(2, "MacBook Pro", 1999.0, "https://picsum.photos/seed/2/300/300"),
Product(3, "AirPods Pro", 249.0, "https://picsum.photos/seed/3/300/300"),
Product(4, "iPad Air", 599.0, "https://picsum.photos/seed/4/300/300"),
)4.2. Tạo ProductCard
@Composable
fun ProductCard(product: Product) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
elevation = CardDefaults.cardElevation(4.dp)
) {
Row(
modifier = Modifier.padding(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Ảnh sản phẩm
AsyncImage(
model = product.imageUrl,
contentDescription = product.name,
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop,
placeholder = ColorPainter(Color.LightGray),
error = ColorPainter(Color.Red.copy(alpha = 0.3f))
)
Spacer(modifier = Modifier.width(16.dp))
// Thông tin sản phẩm
Column {
Text(
text = product.name,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Spacer(modifier = Modifier.height(4.dp))
Text(
text = "$${product.price}",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.primary
)
}
}
}
}4.3. Tạo ProductList
@Composable
fun ProductList(products: List<Product>) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(8.dp)
) {
items(products, key = { it.id }) { product ->
ProductCard(product)
}
}
}
// Sử dụng
@Composable
fun ProductScreen() {
ProductList(products = sampleProducts)
}Bước 5: Caching và Performance
Coil tự động cache ảnh để không phải download lại. Nhưng bạn có thể tùy chỉnh:
5.1. Custom ImageLoader
// Trong Application class hoặc Hilt module
val imageLoader = ImageLoader.Builder(context)
.crossfade(true) // Animation fade khi load xong
.crossfade(300) // Duration 300ms
.memoryCachePolicy(CachePolicy.ENABLED) // Cache trong RAM
.diskCachePolicy(CachePolicy.ENABLED) // Cache trên disk
.build()
// Sử dụng
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data("https://example.com/image.jpg")
.crossfade(true)
.build(),
contentDescription = null,
imageLoader = imageLoader
)5.2. Preload ảnh (load trước)
val context = LocalContext.current
val imageLoader = context.imageLoader
// Preload ảnh trước khi hiển thị
LaunchedEffect(Unit) {
val request = ImageRequest.Builder(context)
.data("https://example.com/large-image.jpg")
.build()
imageLoader.enqueue(request)
}Bước 6: Các tình huống đặc biệt
6.1. Ảnh tròn (Avatar)
AsyncImage(
model = "https://example.com/avatar.jpg",
contentDescription = "Avatar",
modifier = Modifier
.size(60.dp)
.clip(CircleShape) // Bo tròn
.border(2.dp, Color.White, CircleShape), // Viền trắng
contentScale = ContentScale.Crop
)6.2. Ảnh với góc bo (Rounded)
AsyncImage(
model = "https://example.com/product.jpg",
contentDescription = "Product",
modifier = Modifier
.size(120.dp)
.clip(RoundedCornerShape(16.dp)), // Bo góc 16dp
contentScale = ContentScale.Crop
)6.3. Load ảnh từ drawable local
AsyncImage(
model = R.drawable.my_image, // Resource ID
contentDescription = "Local image"
)6.4. Load ảnh từ File
val file = File("/path/to/image.jpg")
AsyncImage(
model = file,
contentDescription = "File image"
)📝 Tóm tắt cho người mới
Các bước cơ bản:
- Thêm dependency:
implementation("io.coil-kt:coil-compose:2.5.0") - Thêm permission INTERNET trong Manifest
- Dùng
AsyncImage(model = url, contentDescription = "...")
Checklist khi dùng Coil:
- Đã thêm dependency?
- Đã thêm INTERNET permission?
- Có placeholder cho loading state?
- Có error image cho trường hợp lỗi?
- Đã set contentScale phù hợp?
Các component chính:
| Component | Khi nào dùng |
|---|---|
AsyncImage | Load ảnh đơn giản |
SubcomposeAsyncImage | Cần custom UI cho loading/error |
rememberAsyncImagePainter | Dùng với Image thường |