Jetpack Compose 中使用 CameraX 拍照和录制视频

在 Android 开发的历史中,Camera 的 API 是一直受人诟病的,使用过的人都知道,直观的感觉就是配置复杂、臃肿、难用、不易理解,从官方关于 Camera 的 API 迭代路线可以看出官方也在尝试着不断改进开发者关于Camera的使用体验,Camera 的 API 截止目前经历了 Camera(已废弃)、Camera2CameraX 三个版本。

在这里插入图片描述

初代 Camera API从 5.0 开始已经宣布废弃,而 Camera2 的 API 特别难用,很多人用过之后直呼还不如以前的 Camera,所以就有了 CameraX ,它其实还是基于 Camera2 的,只不过使用上做了一些更人道的优化,它是 Jetpack 组件库的一部分,目前也是官方强推的 Camera 方案。所以,如果你有新项目涉及 Camera 的 API 或者打算对旧的 Camera API 进行升级,建议直接使用 CameraX。

本文主要探索如何在 Jetpack Compose 中使用 CameraX。

CameraX 准备工作

首先添加依赖:

dependencies {
    
  def camerax_version = "1.3.0-alpha04" 
  // implementation "androidx.camera:camera-core:${camerax_version}" // 可选,因为camera-camera2 包含了camera-core
  implementation "androidx.camera:camera-camera2:${
     camerax_version}" 
  implementation "androidx.camera:camera-lifecycle:${
     camerax_version}" 
  implementation "androidx.camera:camera-video:${
     camerax_version}" 
  implementation "androidx.camera:camera-view:${
     camerax_version}"  
  implementation "androidx.camera:camera-extensions:${
     camerax_version}"
}

注,以上各个库的最新版本信息可以在这里查找:https://developer.android.com/jetpack/androidx/releases/camera?hl=zh-cn

由于使用相机需要进行Camera权限申请,所以还需要添加一个accompanist-permissions依赖用于在 Compose 中申请权限:

val accompanist_version = "0.31.2-alpha"
implementation "com.google.accompanist:accompanist-permissions:$accompanist_version"

注,以上库的最新版本信息可以在这里查找:https://github.com/google/accompanist/releases

然后记得在 AndroidManifest.xml 中添加权限声明:

<manifest .. >
    <uses-permission android:name="android.permission.CAMERA" />
    ..
</manifest>

CameraX 具有以下最低版本要求:

  • Android API 级别 21
  • Android 架构组件 1.1.1

对于能够感知生命周期的 Activity,请使用 FragmentActivity 或 AppCompatActivity。

CameraX 相机预览

下面主要看一下 CameraX 如何进行相机预览

创建预览 PreviewView

由于 Jetpack Compose 中目前并没有直接提供一个单独的组件来专门用于Camera预览,因此办法还是使用 AndroidView 这个 Composable 来将原生的预览控件集成到 Compose 中显示。代码如下:

@Composable
private fun CameraPreviewExample() {
   
    Scaffold(modifier = Modifier.fillMaxSize()) {
    innerPadding: PaddingValues ->
        AndroidView(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding),
            factory = {
    context ->
                PreviewView(context).apply {
   
                    setBackgroundColor(Color.White.toArgb())
                    layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
                    scaleType = PreviewView.ScaleType.FILL_START
                    implementationMode = PreviewView.ImplementationMode.COMPATIBLE
                }
            }
        )
    }
}

这里的 PreviewViewcamera-view 库中的一个原生View控件,下面看一下它的几个设置方法:

1.PreviewView.setImplementationMode() :该方法用于设置适合应用的具体实现模式

实现模式

PreviewView 可以使用以下模式之一将预览流渲染到目标 View 上:

  • PERFORMANCE 是默认模式,PreviewView 会使用 SurfaceView 显示视频串流,但在某些情况下会回退为使用 TextureViewSurfaceView 具有专用的绘图界面,该对象更有可能通过内部硬件合成器实现硬件叠加层,尤其是当预览视频上面没有其他界面元素(如按钮)时。通过使用硬件叠加层进行渲染,视频帧会避开 GPU 路径,从而能降低平台功耗并缩短延迟时间。

  • COMPATIBLE 模式,在此模式下,PreviewView 会使用 TextureView。不同于 SurfaceView,该对象没有专用的绘图表面。因此,视频要通过混合渲染,才能显示。在这个额外的步骤中,应用可以执行额外的处理工作,例如不受限制地缩放和旋转视频。

注:对于 PERFORMANCE 是默认模式,如果设备不支持SurfaceView,则PreviewView将回退为使用TextureView。当API级别为24或更低、相机硬件支持级别为CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACYPreview.getTargetRotation()PreviewView的显示旋转不同时,PreviewView会返回到TextureView
如果Preview.Builder.setTargetRotation(int) 设置为不同于显示器旋转的值,请不要使用此模式,因为SurfaceView不支持任意旋转。如果“预览视图”需要设置动画,请不要使用此模式。API 24级或更低级别不支持SurfaceView动画。此外,对于getPreviewStreamState中提供的预览流状态,如果使用此模式,PreviewView.StreamState.streaming状态可能会提前发生。

显然如果是为了性能考虑应该使用 PERFORMANCE 模式,但如果是为了兼容性考虑最好使用 COMPATIBLE 模式。

2.PreviewView.setScaleType() :该方法用于设置最适合应用的缩放类型

缩放类型

当预览视频分辨率与目标 PreviewView 的尺寸不同时,视频内容需要通过剪裁操作或添加遮幅式黑边来适应视图(保持原始宽高比)。为此,PreviewView 提供了以下 ScaleTypes

  • FIT_CENTERFIT_STARTFIT_END,用于添加遮幅式黑边。整个视频内容会调整(放大或缩小)为可在目标 PreviewView 中显示的最大尺寸。不过,虽然整个视频帧会完整显示,但屏幕画面中可能会出现空白部分。视频帧会与目标视图的中心、起始或结束位置对齐,具体取决于您在上述三种缩放类型中选择了哪一种。

  • FILL_CENTERFILL_STARTFILL_END,用于进行剪裁。如果视频的宽高比与 PreviewView 不匹配,画面中只会显示部分内容,但视频仍会填满整个 PreviewView

CameraX 使用的默认缩放类型是 FILL_CENTER

注意:缩放类型主要目的是为了保持预览时不会出现拉伸变形问题,如果是使用以前的Camera或Camera2 API,我的一般做法是获取相机支持的预览分辨率列表,选择一种预览分辨率,然后将SurfaceViewTextureView控件的宽高比对齐到所选择的预览分辨率的宽高比,这样就不会出现预览时拉伸变形问题,最终的效果其实跟上面的缩放类型如出一辙。幸运的是,现在有了官方API级别的支持,开发者再也不用手动做这些麻烦事了。

例如,下面左图是正常预览显示效果,而右图是拉伸变形的预览显示效果:

在这里插入图片描述

这种体验非常不好,最大的问题就是不能做到所见即所得(保存的图片或视频文件跟预览时看到效果不一致)。

以4:3的图片显示到16:9的预览屏为例,如果不做处理,是百分百会出现拉伸变形的:

在这里插入图片描述

下图是应用了不同缩放类型的效果:

在这里插入图片描述

使用 PreviewView 存在一些限制。使用 PreviewView 时,您无法执行以下任何操作:

  • 创建 SurfaceTexture,以在 TextureViewPreview.SurfaceProvider 上进行设置。
  • TextureView 检索 SurfaceTexture,并在 Preview.SurfaceProvider 上对其进行设置。
  • SurfaceView 获取 Surface,并在 Preview.SurfaceProvider 上对其进行设置。

如果出现上述任何一种情况,Preview 就会停止将帧流式传输到 PreviewView

绑定生命周期 CameraController

在创建PreviewView后,下一步需要为我们创建的实例设置一个CameraController,它是一个抽象类,其实现是LifecycleCameraController,然后我们可以使用CameraController将创建的实例绑定到当前生命周期持有者lifecycleOwner上。代码如下:

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun CameraPreviewExample() {
   
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val cameraController = remember {
    LifecycleCameraController(context) }

    Scaffold(modifier = Modifier.fillMaxSize()) {
    innerPadding: PaddingValues ->
        AndroidView(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding),
            factory = {
    context ->
                PreviewView(context).apply {
   
                    setBackgroundColor(Color.White.toArgb())
                    layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
                    scaleType = PreviewView.ScaleType.FILL_START
                    implementationMode = PreviewView.ImplementationMode.COMPATIBLE
                }.also {
    previewView ->
                    previewView.controller = cameraController
                    cameraController.bindToLifecycle(lifecycleOwner)
                }
            },
            onReset = {
   },
            onRelease = {
   
                cameraController.unbind()
            }
        )
    }
}

请注意,在上面代码中,我们在onRelease回调中从PreviewView中解除控制器的绑定。这样,我们可以确保在不再使用AndroidView时,相机资源能够得到释放。

申请权限

只有应用获取了Camera授权之后,才显示预览的Composable界面,否则显示一个占位的Composable界面。获取授权的参考代码如下:

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun ExampleCameraScreen() {
   
    val cameraPermissionState = rememberPermissionState(android.Manifest.permission.CAMERA)
    LaunchedEffect(key1 = Unit) {
   
        if (!cameraPermissionState.status.isGranted && !cameraPermissionState.status.shouldShowRationale) {
   
            cameraPermissionState.launchPermissionRequest()
        }
    }
    if (cameraPermissionState.status.isGranted) {
    // 相机权限已授权, 显示预览界面
        CameraPreviewExample()
    } else {
    // 未授权,显示未授权页面
        NoCameraPermissionScreen(cameraPermissionState = cameraPermissionState)
    }
}

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NoCameraPermissionScreen(cameraPermissionState: PermissionState) {
   
    // In this screen you should notify the user that the permission
    // is required and maybe offer a button to start another camera perission request
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
   
        val textToShow = if (cameraPermissionState.status.shouldShowRationale) {
   
            // 如果用户之前选择了拒绝该权限,应当向用户解释为什么应用程序需要这个权限
            "未获取相机授权将导致该功能无法正常使用。"
        } else {
   
            // 首次请求授权
            "该功能需要使用相机权限,请点击授权。"
        }
        Text(textToShow)
        Spacer(Modifier.height(8.dp))
        Button(onClick = {
    cameraPermissionState.launchPermissionRequest() }) {
    Text("请求权限") }
    }
}

更多关于如何在 Compose 中进行动态权限申请请参考 Jetpack Compose 中的 Accompanist,这里不再赘述。

全屏设置

为了相机预览时全屏展示,没有顶部的状态栏,可以在ActivityonCreate()方法中setContent之前加入以下代码:

if (isFullScreen) {
   
   requestWindowFeature(Window.FEATURE_NO_TITLE)
    //这个必须设置,否则不生效。
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
   
        window.attributes.layoutInDisplayCutoutMode =
            WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES
    }
    WindowCompat.setDecorFitsSystemWindows(window, false)
    val windowInsetsController = WindowCompat.getInsetsController(window, window.decorView)
    windowInsetsController.hide(WindowInsetsCompat.Type.statusBars()) // 隐藏状态栏
    windowInsetsController.hide(WindowInsetsCompat.Type.navigationBars()) // 隐藏导航栏
    //将底部的navigation操作栏弄成透明,滑动显示,并且浮在上面
    windowInsetsController.systemBarsBehavior = WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE;
}

通常这个代码应该会起作用,如果不行,可尝试修改theme主题:

// themes.xml
<?xml version="1.0" encoding="utf-8"?>
<resources> 
    <style name="Theme.MyComposeApplication" parent="android:Theme.Material.Light.NoActionBar.Fullscreen" >
        <item name="android:statusBarColor">@android:color/transparent</item>
        <item name="android:navigationBarColor">@android:color/transparent</item>
        <item name="android:windowTranslucentStatus">true</item>
    </style> 
</resources>

CameraX 拍照

CameraX 中拍照主要提供了两个重载方法:

  • takePicture(Executor, OnImageCapturedCallback):此方法为拍摄的图片提供内存缓冲区。
  • takePicture(OutputFileOptions, Executor, OnImageSavedCallback):此方法将拍摄的图片保存到提供的文件位置。

我们添加一个FloatingActionButtonCameraPreviewExample中,该按钮将用作点击时触发拍照功能。代码如下:

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun CameraPreviewExample() {
   
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val cameraController = remember {
    LifecycleCameraController(context) }

    Scaffold(
        modifier = Modifier.fillMaxSize(),
        floatingActionButton = {
   
            FloatingActionButton(onClick = {
    takePhoto(context, cameraController) }) {
   
                Icon(
                    imageVector = ImageVector.vectorResource(id = R.drawable.ic_camera_24),
                    contentDescription = "Take picture"
                )
            }
        },
        floatingActionButtonPosition = FabPosition.Center,
    ) {
    innerPadding: PaddingValues ->
        AndroidView(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding),
            factory = {
    context -> 
                PreviewView(context).apply {
   
                    setBackgroundColor(Color.White.toArgb())
                    layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
                    scaleType = PreviewView.ScaleType.FILL_START
                    implementationMode = PreviewView.ImplementationMode.COMPATIBLE
                }.also {
    previewView ->
                    previewView.controller = cameraController
                    cameraController.bindToLifecycle(lifecycleOwner)
                }
            },
            onReset = {
   },
            onRelease = {
   
                cameraController.unbind() 
            }
        )
    }
}
fun takePhoto(context: Context, cameraController: LifecycleCameraController) {
   
    val mainExecutor = ContextCompat.getMainExecutor(context)
    // Create time stamped name and MediaStore entry.
    val name = SimpleDateFormat(FILENAME, Locale.CHINA)
        .format(System.currentTimeMillis())
    val contentValues = ContentValues().apply {
   
        put(MediaStore.MediaColumns.DISPLAY_NAME, name)
        put(MediaStore.MediaColumns.MIME_TYPE, PHOTO_TYPE)
        if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
   
            val appName = context.resources.getString(R.string.app_name)
            put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/${
     appName}")
        }
    }
    // Create output options object which contains file + metadata
    val outputOptions = ImageCapture.OutputFileOptions
        .Builder(context.contentResolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            contentValues)
        .build()
    cameraController.takePicture(outputOptions, mainExecutor, object : ImageCapture.OnImageSavedCallback {
   
            override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
   
                val savedUri = outputFileResults.savedUri
                Log.d(TAG, "Photo capture succeeded: $savedUri")
                context.notifySystem(savedUri)
            }
            override fun onError(exception: ImageCaptureException) {
   
                Log.e(TAG, "Photo capture failed: ${
     exception.message}", exception)
            }
        }
    )
    context.showFlushAnimation()
}

OnImageSavedCallbackonImageSaved 方法回调中能够通过outputFileResults获取到保存的图片文件的Uri, 然后进一步做业务处理。

如果拍照后想自己执行保存逻辑,或者不保存只是用来展示,可以使用另一个回调OnImageCapturedCallback

fun takePhoto2(context: Context, cameraController: LifecycleCameraController) {
   
    val mainExecutor = ContextCompat.getMainExecutor(context)
    cameraController.takePicture(mainExecutor,  object : ImageCapture.OnImageCapturedCallback() {
   
        override fun onCaptureSuccess(image: ImageProxy) {
   
            Log.e(TAG, "onCaptureSuccess: ${
     image.imageInfo}")
            // Process the captured image here
            try {
   
                // The supported format is ImageFormat.YUV_420_888 or PixelFormat.RGBA_8888.
                val bitmap = image.toBitmap()
                Log.e(TAG, "onCaptureSuccess bitmap: ${
     bitmap.width} x ${
     bitmap.height}")
            } catch (e: Exception) {
   
                Log.e(TAG, "onCaptureSuccess Exception: ${
     e.message}")
            }
        }
    })
    context.showFlushAnimation()
}

该回调中可以利用ImageProxy#toBitmap 方法方便的将拍照后的原始数据转成 Bitmap 来显示。不过这里得到的默认格式是ImageFormat.JPEG,用toBitmap方法转换会失败,可以参考如下代码来解决:

fun takePhoto2(context: Context, cameraController: LifecycleCameraController) {
   
    val mainExecutor = ContextCompat.getMainExecutor(context)
    cameraController.takePicture(mainExecutor,  object : ImageCapture.OnImageCapturedCallback() {
   
        override fun onCaptureSuccess(image: ImageProxy) {
   
            Log.e(TAG, "onCaptureSuccess: ${
     image.format}")
            // Process the captured image here
            try {
   
                var bitmap: Bitmap? = null
                // The supported format is ImageFormat.YUV_420_888 or PixelFormat.RGBA_8888.
                if (image.format == ImageFormat.YUV_420_888 || image.format == PixelFormat.RGBA_8888) {
   
                    bitmap = image.toBitmap()
                } else if (image.format == ImageFormat.JPEG) {
   
                    val planes = image.planes
                    val buffer = planes[0].buffer // 因为是ImageFormat.JPEG格式,所以 image.getPlanes()返回的数组只有一个,也就是第0个。
                    val size = buffer.remaining()
                    val bytes = ByteArray(size)
                    buffer.get(bytes, 0, size)
                    // ImageFormat.JPEG格式直接转化为Bitmap格式。
                    bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
                }
                if (bitmap != null) {
   
                    Log.e(TAG, "onCaptureSuccess bitmap: ${
     bitmap.width} x ${
     bitmap.height}")
                }
            } catch (e: Exception) {
   
                Log.e(TAG, "onCaptureSuccess Exception: ${
     e.message}")
            }
        }
    })
    context.showFlushAnimation()
}

如果这里得到的是 YUV 格式,除了直接调用image.toBitmap()方法外,官方还提供了一个工具类可以将 YUV_420_888 格式转换为 RGB 格式的Bitmap对象,请参阅 YuvToRgbConverter.kt

以上示例的完整代码:

@Composable
fun ExampleCameraNavHost() {
   
    val navController = rememberNavController()
    NavHost(navController, startDestination = "CameraScreen") {
   
        composable("CameraScreen") {
   
            ExampleCameraScreen(navController = navController)
        }
        composable("ImageScreen") {
   
            ImageScreen(navController = navController)
        }
    }
}

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun ExampleCameraScreen(navController: NavHostController) {
   
    val cameraPermissionState = rememberPermissionState(Manifest.permission.CAMERA)
    LaunchedEffect(key1 = Unit) {
   
        if (!cameraPermissionState.status.isGranted && !cameraPermissionState.status.shouldShowRationale) {
   
            cameraPermissionState.launchPermissionRequest()
        }
    }
    if (cameraPermissionState.status.isGranted) {
    // 相机权限已授权, 显示预览界面
        CameraPreviewExample(navController)
    } else {
    // 未授权,显示未授权页面
        NoCameraPermissionScreen(cameraPermissionState = cameraPermissionState)
    }
}

@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun NoCameraPermissionScreen(cameraPermissionState: PermissionState) {
   
    // In this screen you should notify the user that the permission
    // is required and maybe offer a button to start another camera perission request
    Column(horizontalAlignment = Alignment.CenterHorizontally) {
   
        val textToShow = if (cameraPermissionState.status.shouldShowRationale) {
   
            // 如果用户之前选择了拒绝该权限,应当向用户解释为什么应用程序需要这个权限
            "未获取相机授权将导致该功能无法正常使用。"
        } else {
   
            // 首次请求授权
            "该功能需要使用相机权限,请点击授权。"
        }
        Text(textToShow)
        Spacer(Modifier.height(8.dp))
        Button(onClick = {
    cameraPermissionState.launchPermissionRequest() }) {
    Text("请求权限") }
    }
}

private const val TAG = "CameraXBasic"
private const val FILENAME = "yyyy-MM-dd-HH-mm-ss-SSS"
private const val PHOTO_TYPE = "image/jpeg"

@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun CameraPreviewExample(navController: NavHostController) {
   
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    val cameraController = remember {
    LifecycleCameraController(context) }

    Scaffold(
        modifier = Modifier.fillMaxSize(),
        floatingActionButton = {
   
            FloatingActionButton(onClick = {
   
                 takePhoto(context, cameraController, navController)
                // takePhoto2(context, cameraController, navController)
                // takePhoto3(context, cameraController, navController)
            }) {
   
                Icon(
                    imageVector = ImageVector.vectorResource(id = R.drawable.ic_camera_24),
                    contentDescription = "Take picture"
                )
            }
        },
        floatingActionButtonPosition = FabPosition.Center,
    ) {
    innerPadding: PaddingValues ->
        AndroidView(
            modifier = Modifier
                .fillMaxSize()
                .padding(innerPadding),
            factory = {
    context ->
                cameraController.imageCaptureMode = CAPTURE_MODE_MINIMIZE_LATENCY
                PreviewView(context).apply {
   
                    setBackgroundColor(Color.White.toArgb())
                    layoutParams = LinearLayout.LayoutParams(MATCH_PARENT, MATCH_PARENT)
                    scaleType = PreviewView.ScaleType.FILL_CENTER
                    implementationMode = PreviewView.ImplementationMode.COMPATIBLE
                }.also {
    previewView ->
                    previewView.controller = cameraController
                    cameraController.bindToLifecycle(lifecycleOwner)
                }
            },
            onReset = {
   },
            onRelease = {
   
                cameraController.unbind()
            }
        )
    }
}

fun takePhoto(context: Context, cameraController: LifecycleCameraController, navController: NavHostController) {
   
    val mainExecutor = ContextCompat.getMainExecutor(context)
    // Create time stamped name and MediaStore entry.
    val name = SimpleDateFormat(FILENAME, Locale.CHINA)
        .format(System.currentTimeMillis())
    val contentValues = ContentValues().apply {
   
        put(MediaStore.MediaColumns.DISPLAY_NAME, name)
        put(MediaStore.MediaColumns.MIME_TYPE, PHOTO_TYPE)
        if(Build.VERSION.SDK_INT > Build.VERSION_CODES.P) {
   
            val appName = context.resources.getString(R.string.app_name)
            put(MediaStore.Images.Media.RELATIVE_PATH, "Pictures/${
     appName}")
        }
    }
    // Create output options object which contains file + metadata
    val outputOptions = ImageCapture.OutputFileOptions
        .Builder(context.contentResolver, MediaStore.Images.Media.EXTERNAL_CONTENT_URI,
            contentValues)
        .build()
    cameraController.takePicture(outputOptions, mainExecutor, object : ImageCapture.OnImageSavedCallback {
   
            override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
   
                val savedUri = outputFileResults.savedUri
                Log.d(TAG, "Photo capture succeeded: $savedUri")
                context.notifySystem(savedUri)

                navController.currentBackStackEntry?.savedStateHandle?.set("savedUri", savedUri)
                navController.navigate("ImageScreen"
  • 2
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

川峰

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值