Cách Compose Preview hoạt động bên dưới hệ thống
Mọi dev Android khi sử dụng Compose đều từng viết @Preview phía trên một composable và quan sát nó xuất hiện trong bảng thiết kế của Studio. Nhưng thực sự điều gì xảy ra giữa annotation đó và các pixel được hiển thị? N là metadata của annotation, việc inflate layout XML, các đối tượng vòng đời (lifecycle) giả lập, việc gọi hàm composable dựa trên reflection và một engine render dựa trên JVM, tất cả phối hợp để khiến một composable "tin" rằng nó đang chạy bên trong một Activity thực thụ. Đùa chứ nghe phức tạp rối rắm thật.
Trong bài viết này, chúng ta sẽ thử khám phá toàn bộ quy trình biến một annotation @Preview thành hình ảnh hiển thị, đi từ định nghĩa của chính annotation đó, thông qua ComposeViewAdapter (FrameLayout điều phối việc render), ComposableInvoker (gọi composable qua reflection trong khi vẫn tuân thủ ABI của trình biên dịch Compose), Inspectable (bật chế độ kiểm tra và ghi lại dữ liệu thành phần), và cây ViewInfo ánh xạ các pixel đã render ngược lại các dòng mã nguồn.

Vấn đề nền tảng: Render thứ "không thể gọi trực tiếp"
Một hàm @Composable không phải là một hàm thông thường. Trình biên dịch Compose biến đổi mọi hàm @Composable để nhận thêm một tham số Composer và các số nguyên tổng hợp $changed và $default. Bên cạnh chữ ký hàm (function signature), các composable mong đợi chạy trong một môi trường cung cấp các lifecycle owner, một ViewModelStore, một SavedStateRegistry, và các đối tượng Android framework khác. Các phụ thuộc này được cung cấp miễn phí bên trong một Activity đang chạy, nhưng Studio cần render composable của bạn mà không cần chạy trình giả lập hoặc thiết bị thật.
Các tooling phải tái cấu trúc đủ môi trường runtime của Android để composable tin rằng nó đang ở trong một Activity thật, gọi composable thông qua reflection trong khi khớp chính xác với chữ ký đã biến đổi của trình biên dịch, sau đó trích xuất thông tin layout đã render để Studio có thể ánh xạ pixel tới mã nguồn. Đây chính là thách thức mà thư viện ui-tooling giải quyết.
Annotation @Preview: Metadata, not behavior
Bản thân annotation @Preview không làm gì khi runtime. Nó thuần túy là metadata mà Studio đọc để cấu hình môi trường render. Nhìn vào định nghĩa của annotation này:
@MustBeDocumented
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION)
@Repeatable
annotation class Preview(
val name: String = "",
val group: String = "",
@IntRange(from = 1) val apiLevel: Int = -1,
val widthDp: Int = -1,
val heightDp: Int = -1,
val locale: String = "",
@FloatRange(from = 0.01) val fontScale: Float = 1f,
val showSystemUi: Boolean = false,
val showBackground: Boolean = false,
val backgroundColor: Long = 0,
@AndroidUiMode val uiMode: Int = 0,
@Device val device: String = Devices.DEFAULT,
@Wallpaper val wallpaper: Int = Wallpapers.NONE,
)
Ba "meta annotation" định nghĩa cách annotation này hoạt động:
@Retention(BINARY): Annotation tồn tại sau khi biên dịch vào bytecode. Điều này cho phép Studio phát hiện các preview bằng cách quét các file class đã biên dịch, không chỉ mã nguồn.
@Target(ANNOTATION_CLASS, FUNCTION): Nó có thể được đặt trên các hàm (composable của bạn) và trên các lớp annotation khác. Mục tiêu thứ hai này chính là thứ cho phép tính năng MultiPreview hoạt động.
@Repeatable: Bạn có thể xếp chồng nhiều annotation @Preview trên một hàm duy nhất để tạo ra nhiều cấu hình preview khác nhau.
Tất cả các tham số như widthDp, heightDp, device, và locale hoàn toàn là dữ liệu cấu hình. Studio đọc chúng để thiết lập khung hình render (viewport), nhưng annotation này không mang hành vi runtime của riêng nó.
MultiPreview: Annotation trên Annotation
Target ANNOTATION_CLASS cho phép một pattern gọi là MultiPreview, nơi bạn tạo một custom annotation mà chính nó được chú thích bởi nhiều @Preview. Nhìn vào @PreviewLightDark:
@Retention(AnnotationRetention.BINARY)
@Target(AnnotationTarget.ANNOTATION_CLASS, AnnotationTarget.FUNCTION)
@Preview(name = "Light")
@Preview(name = "Dark", uiMode = UI_MODE_NIGHT_YES or UI_MODE_TYPE_NORMAL)
annotation class PreviewLightDark
Khi bạn chú thích một composable với @PreviewLightDark, Studio sẽ giải quyết hai annotation @Preview một cách bắc cầu và tạo ra hai cấu hình preview. Đây là một tính năng annotation thuần túy của Kotlin, không liên quan đến trình biên dịch đặc biệt hay việc tạo mã (code generation).
Từ Annotation đến XML: Cách Studio phát hiện Preview
Quy trình biến đổi từ annotation sang rendered preview bắt đầu bên trong chính Android Studio. Studio sử dụng PSI và UAST (các framework phân tích mã nội bộ của nó) để quét các file nguồn Kotlin tìm annotation @Preview. Nó giải quyết các MultiPreview một cách bắc cầu, thu thập mọi cấu hình preview. Việc quét này diễn ra trong mã nguồn đóng của Studio, nhưng kết quả đầu ra của nó lại hoàn toàn là mã nguồn mở.
Đối với mỗi preview được phát hiện, Studio tạo ra một layout XML tổng hợp tham chiếu đến ComposeViewAdapter với các thuộc tính namespace tools:. Một biểu diễn mang tính khái niệm như sau:
<androidx.compose.ui.tooling.ComposeViewAdapter
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:composableName="com.example.MyPreviewKt.MyPreview"
tools:parameterProviderClass="com.example.MyProvider" />
Mấu chốt quan trọng là cầu nối giữa Studio (nguồn đóng) và bộ công cụ (nguồn mở) là một layout XML, cùng một cơ chế mà Android luôn sử dụng để render trong thời gian thiết kế. ComposeViewAdapter phân tích các thuộc tính này trong phương thức init của nó. Nhìn vào logic phân tích (đã được đơn giản hóa):
private fun init(attrs: AttributeSet) {
setViewTreeLifecycleOwner(FakeSavedStateRegistryOwner)
setViewTreeSavedStateRegistryOwner(FakeSavedStateRegistryOwner)
setViewTreeViewModelStoreOwner(FakeViewModelStoreOwner)
addView(composeView)
val composableName = attrs.getAttributeValue(TOOLS_NS_URI, "composableName")
?: return
val className = composableName.substringBeforeLast('.')
val methodName = composableName.substringAfterLast('.')
val parameterProviderClass = attrs
.getAttributeValue(TOOLS_NS_URI, "parameterProviderClass")
?.asPreviewProviderClass()
init(className = className, methodName = methodName, ...)
}
Phương thức này đọc tools:composableName, tách nó thành tên class và tên method, trích xuất thông tin lớp cung cấp tham số tùy chọn, và ủy quyền cho phương thức init chính để thiết lập composition. Trước đó, nó cài đặt các lifecycle owner giả lập mà composable sẽ cần.
ComposeViewAdapter: Người điều phối
ComposeViewAdapter là một FrameLayout nằm ở trung tâm của quy trình preview. Nó thiết lập một vòng đời Android giả lập, gọi composable, bắt các ngoại lệ và xử lý kết quả thành một định dạng mà Studio có thể tiêu thụ.
Giả lập vòng đời Android
Hãy coi vòng đời giả lập như một bối cảnh phim: nó trông đủ giống một tòa nhà thật từ bên ngoài để các diễn viên (composable của bạn) có thể biểu diễn các cảnh quay, nhưng không có gì đằng sau mặt tiền đó. Các composable truy cập lifecycle owner thông qua các CompositionLocal như LocalLifecycleOwner và LocalViewModelStoreOwner. Nếu không có các implementation thực tế, việc thực thi sẽ thất bại ngay lập tức.
FakeSavedStateRegistryOwner triển khai SavedStateRegistryOwner và cung cấp một lifecycle:
private val FakeSavedStateRegistryOwner =
object : SavedStateRegistryOwner {
val lifecycleRegistry = LifecycleRegistry.createUnsafe(this)
private val controller =
SavedStateRegistryController.create(this).apply {
performRestore(Bundle())
}
init {
lifecycleRegistry.currentState = Lifecycle.State.RESUMED
}
override val savedStateRegistry: SavedStateRegistry
get() = controller.savedStateRegistry
override val lifecycle: LifecycleRegistry
get() = lifecycleRegistry
}
Vòng đời được đặt ngay lập tức thành RESUMED để các composable hành động như thể chúng đang ở trong một Activity đang hoạt động hoàn toàn. SavedStateRegistryController được khôi phục với một Bundle trống, cung cấp vừa đủ hạ tầng trạng thái cho việc composition thành công.
FakeActivityResultRegistryOwner lại chọn một cách tiếp cận khác. Thay vì cung cấp một implementation hoạt động, nó cố tình ném ra ngoại lệ:
private val FakeActivityResultRegistryOwner =
object : ActivityResultRegistryOwner {
override val activityResultRegistry =
object : ActivityResultRegistry() {
override fun <I : Any?, O : Any?> onLaunch(
requestCode: Int,
contract: ActivityResultContract<I, O>,
input: I,
options: ActivityOptionsCompat?,
) {
throw IllegalStateException(
"Calling launch() is not supported in Preview"
)
}
}
}
Đây là một lựa chọn thiết kế có chủ ý. Bộ công cụ cung cấp vừa đủ hạ tầng để việc composition thành công, nhưng không hỗ trợ các side-effect yêu cầu một Activity thực sự. Nếu composable của bạn cố gắng kích hoạt một activity result contract, bạn sẽ nhận được một thông báo lỗi rõ ràng thay vì một thất bại âm thầm.
WrapPreview và chuỗi Composition
Trước khi composable của bạn chạy, ComposeViewAdapter bọc nó trong một chuỗi composition cung cấp tất cả các context cần thiết:
@Composable
private fun WrapPreview(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalFontLoader provides LayoutlibFontResourceLoader(context),
LocalFontFamilyResolver provides createFontFamilyResolver(context),
LocalOnBackPressedDispatcherOwner provides FakeOnBackPressedDispatcherOwner,
LocalActivityResultRegistryOwner provides FakeActivityResultRegistryOwner,
) {
Inspectable(slotTableRecord, content)
}
}
LayoutlibFontResourceLoader thay thế trình tải font tiêu chuẩn vì ResourcesCompat không thể tải font bên trong Layoutlib, engine render dựa trên JVM mà Studio sử dụng. Chuỗi composition diễn ra theo trình tự: WrapPreview → Inspectable → composable của bạn.
Xử lý ngoại lệ
Các ngoại lệ trong quá trình composition gây ra một vấn đề. Compose cần dọn dẹp trạng thái nội bộ của nó trước khi một ngoại lệ lan rộng, nhưng Studio cần hiển thị thông tin lỗi cho nhà phát triển. Giải pháp là một pattern "ném lỗi trễ" (delayed throw).
Các ngoại lệ được bắt trong quá trình composition, được lưu trữ trong một trường delayedException, và được ném ra lại trong hàm onLayout:
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {
super.onLayout(changed, left, top, right, bottom)
delayedException.throwIfPresent()
processViewInfos()
if (composableName.isNotEmpty()) {
findAndTrackAnimations()
}
}
Studio bắt các ngoại lệ trong quá trình layout và hiển thị chúng trên bảng lỗi của preview, đó là lý do bạn thấy các thông báo lỗi dễ đọc thay vì các stack trace thô khi preview thất bại.
ComposableInvoker: Gọi thứ "không thể gọi"
ComposableInvoker chịu trách nhiệm cho phần kỹ thuật khó khăn nhất của quy trình: gọi một hàm @Composable thông qua reflection trong khi phải khớp chính xác với chữ ký nhị phân mà trình biên dịch Compose tạo ra.
Các tham số ẩn của trình biên dịch
Khi bạn viết @Composable fun MyPreview(), trình biên dịch không để nó dưới dạng một hàm không tham số. Kết quả biên dịch trông gần giống với fun MyPreview($composer: Composer, $changed: Int). Đối với các composable có tham số, trình biên dịch cũng thêm các số nguyên bitmask $default để theo dõi tham số nào nên sử dụng giá trị mặc định của chúng. Bộ gọi (invoker) phải xây dựng một mảng đối số khớp chính xác với chữ ký đã biến đổi này.
Tính toán ABI
Số lượng tham số tổng hợp phụ thuộc vào việc hàm có bao nhiêu tham số thực. Mỗi số nguyên $changed sử dụng 3 bit cho mỗi vị trí tham số để theo dõi xem một tham số có thay đổi kể từ lần composition trước hay không. Với 31 bit khả dụng cho mỗi số nguyên, mỗi số nguyên $changed có thể theo dõi 10 vị trí tham số. Mỗi số nguyên $default sử dụng 1 bit cho mỗi tham số, chứa được 31 tham số mỗi số nguyên.
Invoker tính toán các con số này bằng hai hàm:
private const val SLOTS_PER_INT = 10
private const val BITS_PER_INT = 31
private fun changedParamCount(realValueParams: Int, thisParams: Int): Int {
if (realValueParams == 0) return 1
val totalParams = realValueParams + thisParams
return ceil(totalParams.toDouble() / SLOTS_PER_INT.toDouble()).toInt()
}
private fun defaultParamCount(realValueParams: Int): Int {
return ceil(realValueParams.toDouble() / BITS_PER_INT.toDouble()).toInt()
}
Một composable không có tham số thực nào vẫn nhận được một số nguyên $changed. Khi số lượng tham số tăng lên, các số nguyên bổ sung được thêm vào để chứa các vị trí dư ra. Ví dụ, một composable có 12 tham số sẽ cần hai số nguyên $changed (ceil(12/10) = 2) và một số nguyên $default (ceil(12/31) = 1).
Xây dựng mảng đối số
Sau khi tính toán được số lượng tham số, invoker xây dựng mảng đối số. Chiến lược là: điền vào các vị trí tham số thực bằng các giá trị được cung cấp hoặc giá trị mặc định của kiểu dữ liệu, truyền Composer, đặt tất cả các số nguyên $changed thành 0 (nghĩa là "không chắc chắn", để Compose đánh giá lại mọi thứ), và đặt tất cả các bit $default thành 1 (nghĩa là "sử dụng giá trị mặc định cho tất cả các tham số").
Nhìn vào logic xây dựng đối số bên trong invokeComposableMethod (đã đơn giản hóa):
val arguments = Array(totalParams) { idx ->
when (idx) {
in 0 until realParams ->
args.getOrElse(idx) { parameterTypes[idx].getDefaultValue() }
composerIndex -> composer
in changedStartIndex until defaultStartIndex -> 0
in defaultStartIndex until totalParams -> 0b111111111111111111111
else -> error("Unexpected index")
}
}
return invoke(instance, *arguments)
Việc đặt $changed thành 0 thông báo cho Compose rằng tất cả các trạng thái tham số là không chắc chắn, vì vậy nó sẽ đánh giá lại mọi thứ thay vì bỏ qua. Việc đặt $default thành toàn bộ bit 1 thông báo cho runtime sử dụng các giá trị mặc định đã khai báo cho mọi tham số. Điều này an toàn cho preview vì Studio hoặc là cung cấp các giá trị tham số thông qua một PreviewParameterProvider hoặc sử dụng các giá trị mặc định.
Điểm nhập công khai
Hàm invokeComposable kết nối mọi thứ lại với nhau. Nó tải lớp theo tên, tìm phương thức composable, và xử lý cả các hàm cấp cao nhất (được biên dịch thành các phương thức tĩnh) và các hàm thành viên của lớp:
fun invokeComposable(
className: String,
methodName: String,
composer: Composer,
vararg args: Any?,
) {
val composableClass = Class.forName(className)
val method = composableClass.findComposableMethod(methodName, *args)
?: throw NoSuchMethodException(
"Composable $className.$methodName not found"
)
method.isAccessible = true
if (Modifier.isStatic(method.modifiers)) {
method.invokeComposableMethod(null, composer, *args)
} else {
val instance = composableClass.getConstructor().newInstance()
method.invokeComposableMethod(instance, composer, *args)
}
}
Đối với các phương thức instance, bộ gọi tạo ra một instance mới bằng hàm khởi tạo trống. Một chi tiết bổ sung đáng lưu ý: việc tìm kiếm phương thức cũng kiểm tra các phương thức bị biến đổi tên (name mangled). Trình biên dịch Compose làm biến dạng tên hàm khi chúng sử dụng các tham số lớp inline, tạo ra các chữ ký như MyPreview-xxxx. Hàm findComposableMethod tìm kiếm cả tên chính xác và biến thể bị biến dạng bằng cách sử dụng it.name.startsWith("$methodName-").
Inspectable: Kích hoạt cầu nối công cụ
Hàm Inspectable là cầu nối giữa việc composition và các công cụ kiểm tra của Studio. Mặc dù chỉ dài vài dòng, nó kích hoạt toàn bộ trải nghiệm kiểm tra cho preview:
@Composable
internal fun Inspectable(
compositionDataRecord: CompositionDataRecord,
content: @Composable () -> Unit,
) {
currentComposer.collectParameterInformation()
val store = (compositionDataRecord as CompositionDataRecordImpl).store
store.add(currentComposer.compositionData)
CompositionLocalProvider(
LocalInspectionMode provides true,
LocalInspectionTables provides store,
content = content,
)
}
Mỗi dòng phục vụ một mục đích riêng biệt:
collectParameterInformation(): yêu cầu Composer ghi lại các giá trị tham số trong quá trình composition. Thông thường bước này bị bỏ qua để tối ưu hiệu năng, vì mã nguồn thực tế không cần kiểm tra các giá trị tham số sau khi composition.
store.add(currentComposer.compositionData): thêm dữ liệu của composition hiện tại vào một tập hợp được hỗ trợ bởi WeakHashMap, làm cho bảng vị trí (slot table) của composition có sẵn để kiểm tra sau này mà không ngăn cản việc thu gom rác.
LocalInspectionMode provides true: chính là dòng khiến LocalInspectionMode.current trả về true trong các composable của bạn. Khi bạn viết if (LocalInspectionMode.current) { ... } để cung cấp hành vi thay thế trong preview, đây chính là nơi giá trị đó bắt nguồn.
LocalInspectionTables provides store: làm cho dữ liệu composition đã ghi lại có thể truy cập được bởi lớp công cụ của Studio để xây dựng cây ViewInfo.
Từ Composition đến ViewInfo: Ánh xạ Pixel tới Mã nguồn
Sau khi composition hoàn tất và layout chạy xong, Studio cần biết hình chữ nhật nào trên màn hình tương ứng với dòng mã nguồn nào. Đây là nơi cấu trúc dữ liệu ViewInfo phát huy tác dụng. Hãy coi ViewInfo như một bảng chú dẫn bản đồ: nó cho Studio biết rằng "hình chữ nhật tại các tọa độ này được tạo ra bởi composable tại tệp và số dòng này".
Nhìn vào data class ViewInfo:
internal data class ViewInfo(
val fileName: String,
val lineNumber: Int,
val bounds: IntRect,
val location: SourceLocation?,
val children: List<ViewInfo>,
val layoutInfo: Any?,
val name: String?,
)
Mỗi ViewInfo mang tên tệp nguồn, số dòng, pixel bounds, và một danh sách các con tạo thành một cây phản chiếu hệ thống phân cấp gọi hàm của composable.
Phương thức processViewInfos duyệt qua dữ liệu composition đã ghi lại và chuyển đổi nó thành cây này:
private fun processViewInfos() {
viewInfos = slotTableRecord.store.makeTree(
prepareResult = {},
createNode = ::toViewInfoFactory,
createResult = { _, out, _ -> out },
)
}
Phương thức này được gọi từ onLayout sau khi kiểm tra ngoại lệ trễ. Hàm makeTree duyệt qua bảng vị trí (slot table) của composition, nơi Compose lưu trữ tất cả trạng thái composition, và xây dựng các node ViewInfo bằng toViewInfoFactory. Hàm factory này trích xuất vị trí nguồn và các hộp bao quanh (bounding boxes) từ mỗi nhóm composition. Kết quả là một cây mà Studio đọc để cung cấp các tính năng như "click để di chuyển tới mã nguồn" trong bảng thiết kế.
Chạy Preview trên thiết bị: PreviewActivity
Cùng một cơ chế ComposableInvoker cung cấp khả năng render trong IDE cũng cho phép chạy preview trực tiếp trên một thiết bị. PreviewActivity là một ComponentActivity đọc tên đầy đủ (fully qualified name) của composable từ một intent extra và gọi nó:
class PreviewActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE == 0) {
Log.d(TAG, "Application is not debuggable. Preview not allowed.")
finish()
return
}
intent?.getStringExtra("composable")?.let {
setComposableContent(it)
}
}
}
Activity đầu tiên kiểm tra cờ FLAG_DEBUGGABLE như một biện pháp bảo mật, vì các preview có thể gọi các composable tùy ý thông qua reflection. Chỉ các bản build debug mới được phép chạy preview trên thiết bị. Phương thức setComposableContent tách tên đầy đủ thành class và method, sau đó gọi trực tiếp ComposableInvoker.invokeComposable, tái sử dụng cùng một cơ chế gọi dựa trên reflection mà bản preview trong IDE sử dụng. Điểm khác biệt là trên thiết bị, composable chạy bên trong một Activity thực sự với vòng đời thực sự, vì vậy các đối tượng vòng đời giả lập là không cần thiết.
Kết luận
Trong bài viết này, bạn đã khám phá toàn bộ quy trình biến đổi một annotation @Preview thành một hình ảnh được render. Hành trình bắt đầu với metadata của annotation (được lưu giữ trong bytecode thông qua @Retention(BINARY)), đi qua lớp quét mã nguồn đóng của Studio để tạo ra layout XML tổng hợp, đi vào lớp mã nguồn mở ComposeViewAdapter để phân tích XML và xây dựng các đối tượng vòng đời giả lập, ủy quyền cho ComposableInvoker để gọi composable thông qua reflection trong khi khớp với ABI của trình biên dịch Compose, đi qua Inspectable để kích hoạt chế độ kiểm tra và ghi lại dữ liệu composition, và cuối cùng tạo ra một cây ViewInfo ánh xạ các pixel ngược lại mã nguồn.
Việc hiểu quy trình này giải thích một số hành vi mà nếu không có thể coi là bí ẩn. Các lỗi render preview thường xảy ra khi composable phụ thuộc vào các thành phần mà vòng đời giả lập không thể cung cấp, chẳng hạn như activity result thực tế hoặc navigation controller thực. Việc kiểm tra LocalInspectionMode.current hoạt động vì Inspectable cung cấp rõ ràng giá trị true. MultiPreview không yêu cầu hỗ trợ công cụ đặc biệt vì nó chỉ là một tính năng annotation của Kotlin mà Studio giải quyết một cách bắc cầu.
Bất kể bạn đang gỡ lỗi một preview từ chối render, sử dụng LocalInspectionMode.current để cung cấp hành vi dự phòng cho các thành phần phụ thuộc vào tài nguyên chỉ có lúc runtime, hay xây dựng các công cụ tùy chỉnh tích hợp với lớp kiểm tra của Compose, việc hiểu cách quy trình này vận hành sẽ cung cấp nền tảng để bạn làm việc cùng hệ thống preview thay vì chống lại nó.
Cảm ơn mọi người đã quan tâm Nguồn : https://doveletter.dev/articles/compose-preview-internals
All rights reserved