Compose CameraX现已稳定:给Composer的端到端指南

本文译自「Compose-Native CameraX Is Now Stable: End-to-End Guide for Jetpack Compose」,原文链接proandroiddev.com/goodbye-and...,由Ioannis Anifantakis发布于20251026。

简介

还记得你在 Jetpack Compose 中的第一个相机页面吗?纯粹的声明式乐趣......直到预览。然后是熟悉的 AndroidView(PreviewView) 绕道。它确实有效,但总感觉不对:composables中间有一个 View 形状的空洞(类似于 _iFrame__ ......),而且点击对焦的数学计算总是让人感觉不太可靠。

在 I/O 25 之后,这种妥协已经结束。

  • 不再 使用 **AndroidView(PreviewView)** 进行相机预览。
  • 新增 **CameraXViewfinder** 可组合项,可在 Compose 中直接渲染 CameraX SurfaceRequest
  • 修正了内置坐标变换(点击对焦、叠加层),并建立了更简洁、更具声明性的心智模型。

注意:

_"在 I/O 25 大会上,Compose 支持已发布 alpha/beta 版本,稳定版已于 9 月发布------现在是时候了解一下了。"

配套项目

你可以在 GitHub 上的配套项目 找到本文的配套项目,该项目演示了 CameraX 中 Jetpack Compose 的新功能。

权限用户体验(简要说明)

本文将重点介绍 Compose + CameraX 的功能。配套项目 实现了完整的运行时流程:

  • 在预览入口点请求 **CAMERA**
  • 仅在用户开始录制时(按需麦克风)请求 **RECORD_AUDIO**
  • 一个小型的 PermissionGate 可组合函数负责处理 Compose 树中的授权/拒绝/重新请求。
  • 为了满足 Lint 对 @RequiresPermission 的要求,调用点还会在调用与麦克风相关的 API 之前执行显式 checkSelfPermission(...)

请参阅代码库,了解具体的 PermissionGate 以及我们如何将其连接到 Capture 页面。

实际变化是什么?

CameraX 团队放弃了 androidx.camera:camera-compose,取而代之的是看似简单的 API:**CameraXViewfinder**。但这不仅仅是"将 PreviewView 封装在可组合项中"。这是对 Compose 的彻底重写,也是对相机 Surface 与 Compose 集成方式的根本性重新思考。

以下是架构层面的变化:

Compose 目标优先 取景器渲染管道现在将 Compose 视为主要平台。Surface 生命周期、旋转处理和缩放都以 Compose 惯用的方式进行。

开箱即用的正确坐标变换 还记得计算预览中的点击实际映射到相机传感器的位置,并考虑旋转、宽高比裁剪和缩放模式吗?MutableCoordinateTransformer 可以处理这些。点击对焦现在......可以正常工作了。

真正的可组合语义 想要将预览 clip() 转换为自定义形状?应用 graphicsLayer 变换?使用 AnimatedContent 为其添加动画效果?现在,你可以轻松完成所有这些操作,而无需与渲染器冲突。它与其他可组合组件一样。

CameraX 1.5.x 成熟度 整个技术栈都得到了完善:适用于 Kotlin 协程的 ProcessCameraProvider.awaitInstance()、全面稳定的构件以及更完善的文档。这并非 Beta 测试......它已准备好投入生产。

为什么这真的很重要

如果你一直在构建相机功能,你就会知道其中的痛点

  • 心智模型分裂:"用 Compose 思考 UI,用 View 思考相机,并在两者之间不断转换。"
  • 手势协调的噩梦:在 Compose 中处理触摸事件,在 View 坐标系中测光对焦,祈祷你的计算准确无误。
  • Z 轴顺序难题:"PreviewView" 经常使用在单独图层中渲染的"SurfaceView"。Compose 叠加层无法可靠地位于顶部,因此十字线、参考线和按钮可能会消失在预览层后面。
  • 生命周期之舞:使用 CameraX 用例绑定将 Compose 重组与 View 生命周期同步

所有这些摩擦?都消失了。

"现在,你可以像编写其他现代 Android 应用一样编写相机 UI。一个范例。一个心智模型。纯粹的 Compose。"

代码演示

让我们从最基本的开始------一个可以工作的相机预览(固定状态模式:将写入器 MutableStateFlow读取器 collectAsState 分离)。

kotlin 复制代码
@Composable
fun CameraPreview(modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    // Writer: MutableStateFlow we can update from CameraX callbacks
    val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }

    // Reader: Compose state derived from the flow
    val surfaceRequest by surfaceRequests.collectAsState(initial = null)

    // Bind CameraX use cases once
    LaunchedEffect(Unit) {
        val provider = ProcessCameraProvider.awaitInstance(context)

        val preview = Preview.Builder().build().apply {
            // When CameraX needs a surface, publish it to Compose
            setSurfaceProvider { request ->
                surfaceRequests.value = request
            }
        }

        provider.unbindAll()
        provider.bindToLifecycle(
            lifecycleOwner,
            CameraSelector.DEFAULT_BACK_CAMERA,
            preview
        )
    }

    // The actual Compose viewfinder
    surfaceRequest?.let { request ->
        CameraXViewfinder(
            surfaceRequest = request,
            modifier = modifier.fillMaxSize()
        )
    }
}

就是这样。没有 AndroidView。没有 PreviewView。只有一个可组合组件,它接收 SurfaceRequest 并进行渲染。

***模式很简洁:"***CameraX 发布 Surface 请求,Compose 处理它们。单向。没有回调在各个世界之间来回切换。"

可选:使用镜头切换按钮(FAB)进行预览(前/后)

kotlin 复制代码
@Composable
fun PreviewWithLensSwitch(modifier: Modifier = Modifier) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
    val surfaceRequest by surfaceRequests.collectAsState(initial = null)

    // remember current lens
    var useFront by rememberSaveable { mutableStateOf(false) }
    val selector = if (useFront) CameraSelector.DEFAULT_FRONT_CAMERA else CameraSelector.DEFAULT_BACK_CAMERA

    // bind when camera selector changes (front/back camera)
    LaunchedEffect(selector) {
        val provider = ProcessCameraProvider.awaitInstance(context)
        val preview = Preview.Builder().build().apply {
            setSurfaceProvider { req -> surfaceRequests.value = req }
        }
        provider.unbindAll()
        provider.bindToLifecycle(lifecycleOwner, selector, preview)
    }

    Box(Modifier.fillMaxSize()) {
        surfaceRequest?.let { req ->
            CameraXViewfinder(surfaceRequest = req, modifier = Modifier.fillMaxSize())
        }
        FloatingActionButton(
            onClick = { useFront = !useFront },
            modifier = Modifier.align(Alignment.BottomEnd).padding(16.dp)
        ) { Icon(Icons.Rounded.Cameraswitch, contentDescription = "Switch camera") }
    }
}

真正的考验:交互式相机控件

旧方法的失败之处就在这里。让我们实现点击对焦和捏合缩放......这些功能过去需要对视图坐标系进行一些 hack(同样使用固定的写入/读取模式):

kotlin 复制代码
@Composable
fun InteractiveCameraPreview(

modifier: Modifier = Modifier,

onFocusTap: (success: Boolean) -> Unit = {}) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    var camera by remember { mutableStateOf<Camera?>(null) }

    val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
    val surfaceRequest by surfaceRequests.collectAsState(initial = null)

    // Bind camera once
    LaunchedEffect(Unit) {
        val provider = ProcessCameraProvider.awaitInstance(context)
        val preview = Preview.Builder().build().apply {
            setSurfaceProvider { req -> surfaceRequests.value = req }
        }

        camera = provider.bindToLifecycle(
            lifecycleOwner,
            CameraSelector.DEFAULT_BACK_CAMERA,
            preview
        )
    }

    // Coordinate transformer: Compose UI → Camera surface
    val coordinateTransformer = remember { MutableCoordinateTransformer() }

    surfaceRequest?.let { request ->
        CameraXViewfinder(
            surfaceRequest = request,
            coordinateTransformer = coordinateTransformer,
            modifier = modifier
                .fillMaxSize()
                .pointerInput(camera) {
                    // Tap-to-focus
                    detectTapGestures { offset ->
                        val cam = camera ?: return@detectTapGestures

                        // Transform Compose coordinates to camera surface
                        val surfacePoint = with(coordinateTransformer) {
                            offset.transform()
                        }

                        val meteringFactory = SurfaceOrientedMeteringPointFactory(
                            request.resolution.width.toFloat(),
                            request.resolution.height.toFloat()
                        )

                        val focusPoint = meteringFactory.createPoint(
                            surfacePoint.x,
                            surfacePoint.y
                        )

                        val action = FocusMeteringAction.Builder(
                            focusPoint,
                            FocusMeteringAction.FLAG_AF or FocusMeteringAction.FLAG_AE
                        ).setAutoCancelDuration(3, TimeUnit.SECONDS).build()

                        cam.cameraControl
                            .startFocusAndMetering(action)
                            .addListener(
                                { onFocusTap(true) },
                                ContextCompat.getMainExecutor(context)
                            )
                    }
                }
                .pointerInput(camera) {
                    // Pinch-to-zoom
                    detectTransformGestures { _, _, zoom, _ ->
                        val cam = camera ?: return@detectTransformGestures
                        val zoomState = cam.cameraInfo.zoomState.value ?: return@detectTransformGestures

                        val newRatio = (zoomState.zoomRatio * zoom).coerceIn(
                            zoomState.minZoomRatio,
                            zoomState.maxZoomRatio
                        )

                        cam.cameraControl.setZoomRatio(newRatio)
                    }
                }
        )
    }
}

看看这个点击对焦的实现。注意你没有做的事情:

  • 无需手动旋转补偿
  • 无需进行坐标映射的宽高比计算
  • 无需进行视图 → 表面 → 传感器坐标链计算
  • 无需进行"祈祷它在横向模式下能正常工作"的漫长测试

MutableCoordinateTransformer 可以处理所有这些。你点击 Compose 坐标系,它会转换为相机坐标系,就完成了。

这就是"技术上可行"和"实际易于实现"之间的区别。

拍摄照片和视频

添加拍摄功能遵循相同的简洁模式------绑定其他用例,并从 Compose 界面触发它们。

我们还将仅在尝试录制时请求麦克风,并使用简单的"PermissionGate"模式(与我们项目在需要时仅请求音频的方法一致)。

kotlin 复制代码
@Composable
fun CameraScreen() {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current

    var camera by remember { mutableStateOf<Camera?>(null) }
    var imageCapture by remember { mutableStateOf<ImageCapture?>(null) }
    var videoCapture by remember { mutableStateOf<VideoCapture<Recorder>?>(null) }
    var activeRecording by remember { mutableStateOf<Recording?>(null) }

    val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
    val surfaceRequest by surfaceRequests.collectAsState(initial = null)

    // Bind all use cases
    LaunchedEffect(Unit) {
        val provider = ProcessCameraProvider.awaitInstance(context)

        val preview = Preview.Builder().build().apply {
            setSurfaceProvider { req -> surfaceRequests.value = req }
        }

        imageCapture = ImageCapture.Builder()
            .setCaptureMode(ImageCapture.CAPTURE_MODE_MINIMIZE_LATENCY)
            .build()

            val recorder = Recorder.Builder()
            .setQualitySelector(QualitySelector.from(Quality.FHD))
            .build()
        videoCapture = VideoCapture.withOutput(recorder)

        camera = provider.bindToLifecycle(
            lifecycleOwner,
            CameraSelector.DEFAULT_BACK_CAMERA,
            preview,
            imageCapture!!,
            videoCapture!!
        )
    }

    Box(modifier = Modifier.fillMaxSize()) {
        // Camera preview
        surfaceRequest?.let { request ->
            CameraXViewfinder(
                surfaceRequest = request,
                modifier = Modifier.fillMaxSize()
            )
        }

        // Compose UI controls
        Row(
            modifier = Modifier
                .align(Alignment.BottomCenter)
                .padding(bottom = 32.dp)
        ) {
            // Capture photo button
            IconButton(
                onClick = { capturePhoto(context, imageCapture) }
            ) {
                Icon(Icons.Default.PhotoCamera, "Take Photo")
            }

            Spacer(modifier = Modifier.width(32.dp))

            // Video record toggle (mic requested only when needed)
            PermissionGate(
                permission = Permission.RECORD_AUDIO,
                // Optional: custom UI if permission is not yet granted
                contentNonGranted = { missing, humanReadable, requestPermissions ->
                    // Minimal, inline UX: re-request directly
                    Button(onClick = { requestPermissions(missing) }) {
                        Text("Grant $humanReadable")
                    }
                }
            ) {
                IconButton(
                    onClick = {
                        activeRecording = toggleRecording(
                            context,
                            videoCapture,
                            activeRecording
                        )
                    }
                ) {
                    Icon(
                        if (activeRecording == null) Icons.Default.RadioButtonUnchecked
                        else Icons.Default.Stop,
                        "Record Video"
                    )
                }
            }
        }
    }
}

private fun capturePhoto(context: Context, imageCapture: ImageCapture?) {
    val capture = imageCapture ?: return

    val name = "IMG_${System.currentTimeMillis()}.jpg"
    val contentValues = ContentValues().apply {
        put(MediaStore.Images.Media.DISPLAY_NAME, name)
        put(MediaStore.Images.Media.MIME_TYPE, "image/jpeg")
        // On Android 10+ you could also set RELATIVE_PATH = "DCIM/CameraX"
    }

    val outputOptions = ImageCapture.OutputFileOptions.Builder(
        context.contentResolver,
        MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
        contentValues
    ).build()

    capture.takePicture(
        outputOptions,
        ContextCompat.getMainExecutor(context),
        object : ImageCapture.OnImageSavedCallback {
            override fun onImageSaved(output: ImageCapture.OutputFileResults) {
                // Success: output.savedUri
            }
            override fun onError(exception: ImageCaptureException) {
                // Handle error
            }
        }
    )
}

private fun toggleRecording(
    context: Context,
    videoCapture: VideoCapture<Recorder>?,
    currentRecording: Recording?
): Recording? {
    val capture = videoCapture ?: return null

    // Stop if already recording
    if (currentRecording != null) {
        currentRecording.stop()
        return null
    }

    // Start new recording
    val name = "VID_${System.currentTimeMillis()}.mp4"
    val contentValues = ContentValues().apply {
        put(MediaStore.Video.Media.DISPLAY_NAME, name)
        // On Android 10+ you could also set RELATIVE_PATH = "DCIM/CameraX"
    }

    val outputOptions = MediaStoreOutputOptions.Builder(
        context.contentResolver,
        MediaStore.Video.Media.EXTERNAL_CONTENT_URI
    ).setContentValues(contentValues).build()

    return capture.output
        .prepareRecording(context, outputOptions)
        .withAudioEnabled() // mic permission is ensured by PermissionGate above
        .start(ContextCompat.getMainExecutor(context)) { event ->
            // Handle recording events (e.g., finalize, error)
        }
}

这是纯粹的 Compose UI 构建。你的相机按钮与预览位于同一个可组合树中。没有桥接逻辑。无需管理单独的 View 层次结构。

迁移策略:PreviewView → CameraXViewfinder

如果你现有的相机代码使用"PreviewView",则迁移路径如下:

迁移前(旧方法):

kotlin 复制代码
@Composable
fun OldCameraPreview() {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val previewView = remember { PreviewView(context) }

    LaunchedEffect(previewView) {
        val provider = ProcessCameraProvider.getInstance(context).get()
        val preview = Preview.Builder().build()
        preview.setSurfaceProvider(previewView.surfaceProvider)
        provider.bindToLifecycle(lifecycleOwner, CameraSelector.DEFAULT_BACK_CAMERA, preview)
    }

    AndroidView(
        factory = { previewView },
        modifier = Modifier.fillMaxSize()
    )
}

迁移后(Compose 原生方法):

kotlin 复制代码
@Composable
fun NewCameraPreview() {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val selector = CameraSelector.DEFAULT_BACK_CAMERA

    val surfaceRequests = remember { MutableStateFlow<SurfaceRequest?>(null) }
    val surfaceRequest by surfaceRequests.collectAsState(initial = null)

    LaunchedEffect(Unit) {
        val provider = ProcessCameraProvider.awaitInstance(context)
        val preview = Preview.Builder().build().apply {
            setSurfaceProvider { req -> surfaceRequests.value = req }
        }
        provider.unbindAll()
        provider.bindToLifecycle(lifecycleOwner, selector, preview)
    }

    surfaceRequest?.let {
        CameraXViewfinder(
            surfaceRequest = it,
            modifier = Modifier.fillMaxSize()
        )
    }
}

关键的思维转变:不再将 View 的"SurfaceProvider"赋予 CameraX,而是将"SurfaceRequest"对象发布到 Compose 状态,并使用"CameraXViewfinder"进行渲染。

所需依赖项

添加到你的 build.gradle.kts 中:

kotlin 复制代码
val cameraxVersion = "1.5.1"dependencies {
    implementation("androidx.camera:camera-core:$cameraxVersion")
    implementation("androidx.camera:camera-camera2:$cameraxVersion")
    implementation("androidx.camera:camera-lifecycle:$cameraxVersion")
    implementation("androidx.camera:camera-video:$cameraxVersion")

    // The new Compose-native viewfinder
    implementation("androidx.camera:camera-compose:$cameraxVersion")
}

清单权限:

xml 复制代码
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />

实现模式(性能 vs. 合成)

CameraXViewfinder 可以通过两种方式渲染预览:

EXTERNAL(SurfaceView 支持)

相机帧在其自己的 Surface 上渲染,由系统在 Compose 绘制通道之外合成。可以想象成"UI 背后的实时视频层"。通常启用硬件叠加 → 最佳性能/延迟。非常适合在标准 UI 后方显示全屏矩形预览。由于它是一个单独的层,因此对_相机像素_的逐像素效果(复杂的裁剪/模糊)不适用。

  • 优点:延迟更低,GPU 负载更少,非常适合全屏预览/录制。
  • 缺点:不受逐像素界面特效(圆角蒙版/模糊)的影响,不会显示在 Compose 屏幕截图中。

嵌入式(TextureView 支持)

相机帧作为GPU 纹理 绘制在 Compose 渲染通道内部 ------类似于可重绘面板,其行为与其他可组合项类似。你可以获得深度裁剪/蒙版/动画/模糊/Z 轴排序,但代价是 GPU 工作量增加,延迟略高。

  • 优点:行为类似于普通界面;裁剪、Alpha 通道、模糊、特殊形状和复杂 Z 轴排序均正常。
  • 缺点:GPU 工作量增加 → 在繁重的界面或中端设备上,延迟/卡顿风险略高。

经验法则

  • 全屏/高性能 → 外部
  • 特殊构图/特效 → 嵌入式

如果你未指定模式,库将选择一个合理的默认模式。强制使用以下方式:

kotlin 复制代码
import androidx.camera.viewfinder.core.ImplementationMode

CameraXViewfinder(
    surfaceRequest = request,
    implementationMode = ImplementationMode.EXTERNAL // or ImplementationMode.EMBEDDED
)

实际操作中的陷阱

坐标变换并非可选 不要将原始 Compose 偏移量传递给测量工厂。务必使用坐标变换器。数学运算看起来很简单,直到你在横屏、可折叠设备或非标准宽高比设备上进行测试。

前置摄像头是镜像的 如果你正在绘制叠加层或处理拍摄的图像,请记住前置摄像头预览默认是镜像的,但拍摄的图像不是。在你的界面/处理逻辑中考虑到这一点。

在真实设备上测试 不同 OEM 的相机行为有所不同。在 Pixel 上完美运行的功能在三星或小米上可能存在问题。在代表性硬件上测试你的关键流程。

权限用户体验 在入口点请求 CAMERA;仅在开始录制时请求 RECORD_AUDIO(这是一种良好做法)。上面的内联 PermissionGate 模式将该逻辑保留在你的 Compose 树中。

高级功能:可折叠和自适应 UI

由于 CameraXViewfinder 只是另一个可组合项,因此可折叠支持非常简单。简单的双窗格布局或全屏布局通常就足够了;如果需要,可以使用 AnimatedContent 来在状态之间添加动画。

kotlin 复制代码
@Composable
fun AdaptiveCameraScreen(surfaceRequest: SurfaceRequest?) {
    val expanded = remember { mutableStateOf(false) } // pretend this reflects window size/hinge state

    AnimatedContent(targetState = expanded.value, label = "layout") { isExpanded ->
        if (isExpanded) {
            Row(Modifier.fillMaxSize()) {
                surfaceRequest?.let {
                    CameraXViewfinder(
                        surfaceRequest = it,
                        modifier = Modifier
                            .weight(1f)
                            .aspectRatio(9f / 16f)
                    )
                }
                Box(Modifier.weight(1f)) { /* CameraControls(Modifier.align(Alignment.Center)) */ }
            }
        } else {
            Box(Modifier.fillMaxSize()) {
                surfaceRequest?.let {
                    CameraXViewfinder(
                        surfaceRequest = it,
                        modifier = Modifier.fillMaxSize()
                    )
                }
                /* CameraControls(Modifier.align(Alignment.BottomCenter)) */
            }
        }
    }
}

测试清单(实用)

  • 验证纵向/横向以及 ContentScale.Crop/Fit 模式下的点击对焦精度。
  • 测试缩放限制;确保捏合和程序化缩放过渡流畅。
  • 切换摄像头(前/后)并重新验证变换 + 镜像行为。
  • 导航离开/后退、旋转和处理配置更改;预览应能够恢复且不闪烁。
  • 在对焦/缩放时录制视频;确保没有表面掉落。

全局展望

此版本的重要性不仅在于它带来的功能,还在于它所传递的信息。

多年来,Android 中的相机开发一直感觉像是二等公民。除了相机页面之外,你可以在任何地方使用 Compose 构建现代 UI,而相机页面则需要你勉强才能与 View 进行互操作。虽然 Compose 确实有效,但编写代码时总感觉像是被束缚了一只手。

camera-compose 不仅仅是一个新产物。CameraX 团队曾说过:"Compose 现在是一流的相机开发平台。"

这意味着:

  • 未来的相机功能将在设计时充分考虑 Compose,而不是对其进行改造。
  • 社区将构建以 Compose 为先的相机库和组件。
  • 最佳实践将围绕可组合相机 UI 不断发展。
  • 文档和示例将反映现代 Android 开发。

我们在整个 Android 生态系统中都看到了这种模式------最初以 View 为中心的 API 正在逐渐获得 Compose 原生的对应版本。camera-compose 就是迄今为止最具影响力的例子之一。

你现在应该做什么

如果你正在开发一个新的相机功能: 从一开始就使用 CameraXViewfinder。甚至不需要考虑 PreviewView。它的代码更简洁,思维模型更简单,你以后会感谢自己的。

如果你已经有相机代码:camera-compose 添加到你的依赖项中,并一次迁移一个页面。从最简单的相机 UI(可能是基本的纯预览页面)开始,熟悉新的 API。然后再处理复杂的部分。

如果你正在构建一个库: 现在是时候将 Compose 原生相机组件添加到你的 SDK中了。开发者正在寻找可组合的相机解决方案,而生态系统已经为他们做好了准备。

延伸阅读

国王已死 / 国王万岁

"AndroidView"相机预览的时代已经结束。如果你要在 2025 年及以后构建相机功能,那么你将使用 Compose 来构建它们。现在终于有了可以正确支持这些功能的工具。

现在,甩掉那个"AndroidView"包装器,编写一些漂亮的相机 UI 吧。

欢迎搜索并关注 公众号「稀有猿诉」 获取更多的优质文章!

保护原创,请勿转载!

相关推荐
阿里云云原生5 小时前
移动端性能监控探索:可观测 Android 采集探针架构与实现
android
雨白5 小时前
玩转 Flow 操作符(一):数据转换与过滤
android·kotlin
二流小码农5 小时前
鸿蒙开发:web页面如何适配深色模式
android·ios·harmonyos
消失的旧时光-19437 小时前
TCP 流通信中的 EOFException 与 JSON 半包问题解析
android·json·tcp·数据
JiaoJunfeng7 小时前
android 8以上桌面图标适配方案(圆形)
android·图标适配
参宿四南河三8 小时前
Android Compose快速入门手册(真的只是入门)
android·app
芦半山8 小时前
Looper究竟在等什么?
android
czhc114007566310 小时前
JAVA1027抽象类;抽象类继承
android·java·开发语言
_Sem11 小时前
KMP实战:从单端到跨平台的完整迁移指南
android·前端·app