探索 Compose 新输入框:BasicTextField2

本文是两篇文章 BasicTextField2: A TextField of Dreams 1/2BasicTextField2: A TextField of Dreams 2/2 的翻译,译者合并为了一篇。本文探索了正在进行中的 BasicTextField2,聊了很多相关的新特性。

翻译整体采用机翻+人工处理的方式,为了阅读连贯,重写了其中部分内容、调整了部分格式,整体花费的时间还是相当长的。觉得不错,欢迎点个赞~

原文开始


省流:Compose 文本团队正在构建下一代的 TextField API,现在可以开始尝试了。 BasicTextField2 **在最新的 foundation 1.6.0 版本(现在还在 beta)**的 text 包中已经可以试用了。你也可以通过文章最后描述的各种渠道向团队提供反馈。
注意

Compose 是分层构建的:Material -> Foundations -> UI。 TextFieldOutlinedTextField 是 Material 组件,它们在 foundations 层上添加了样式,底层基于 BasicTextField 。在本文中,我们将描述 BasicTextField2 ,它旨在取代 BasicTextField请注意BasicTextField2 在 API 开发过程中是一个临时名称。


Compose 的各个层级及每个 API 的位置

回望一下

咱们大多数估计是通过下面的代码接触到 Jetpack Compose 中的 TextField (或 BasicTextField )API 的:

kotlin 复制代码
var textField by rememberSaveable { mutableStateOf("") }
TextField(
  value = textField,
  onValueChange = { textField = it },
)

这个简单的 API 存在一系列缺点,其中最明显的是:

  • 使用 onValueChange 回调更新 BasicTextField 状态时,很容易引入异步行为,导致不可预测的行为。这个问题非常复杂,在下面这篇博文中有详细描述:
  • VisualTransformation 也容易令人困惑( 并造成 bug )。以电话号码格式化为例,通常,您希望在输入电话号码时通过添加空格、破折号、括号来修改它。要使用 VisualTransformation API 实现这一点,您需要指定初始字符和转换后字符之间的映射关系。


Visual Transformations 中的手动映射

而编写这些映射关系并不容易。


扩展 VisualTransformation 时格式化电话号码

kotlin 复制代码
private val phoneNumberFilter = VisualTransformation { text ->
    val trimmed = if (text.text.length >= 10) text.text.substring(0..9) else text.text
    val filled = trimmed + "_".repeat(10 - trimmed.length)
    val res = "(" + filled.substring(0..2) + ") " + filled.substring(3..5) + "-" + filled.substring(6..9)
    TransformedText(AnnotatedString(text = res), phoneNumberOffsetTranslator(text.text))
}

于是, ValidatingOffsetMapping诞生 了 ,它的目的是验证 VisualTransformation ,并在映射错误时抛出有意义的异常信息。它被设计为向开发者提供更多信息,以更好地理解 crash 并进行调试。在此之前,如果碰到崩溃,那几乎没有信息能帮助找到问题。然而,这个改变并没有从解决上根本问题。

整体的 TextField API 也可以改进。例如,配置单行非常简单:我们只需定义 singleLine = true 。然而,像下面这样写最终是什么结果,就不是很清楚:

kotlin 复制代码
TextField(
  value = textField,
  onValueChange = { textField = it },
  singleLine = true,
  minLines = 2,
  maxLines = 4
)

此外,当前的 API 很难确定编辑过程中的确切更改,只能把这个工作留给开发者。想象一下,您希望在文档协作工具中显示每个用户所做的更改列表。

kotlin 复制代码
TextField(
  value = text,
  // 这里到底发生了什么变化?你只有旧值和新值。
  onValueChange = { newFullText -> /***/ }
)

考虑到所有这些以及其他一些限制,Compose 团队聚集在一起,探讨了典型的 Text Field 使用情况,并想象了理想的 TextField API 是什么样的。

下面的部分是对这些想法如何付诸实践并最终成为 BasicTextField2的探索。

看看新的

定义状态

我们有一个注册界面。对于"用户名"字段,在之前我们可能会这样定义 TextField :

kotlin 复制代码
var username by rememberSaveable { mutableStateOf("") }
BasicTextField(
    value = username,
    onValueChange = { username = it },
)

而新的 API BasicTextField2 的使用方式如下:

kotlin 复制代码
val username = rememberTextFieldState()
BasicTextField2(state = username)

我们不再需要回调函数,这样可以避免之前提到的引入异步行为的错误。

使用 rememberTextFieldState 来定义类型为 TextFieldState 的状态变量。您可以方便地配置 initialText ,以及初始选区和光标位置。使用这个 API 定义的状态在重组、Configuration 变化时都能保留。 使用 rememberTextFieldState 形式上其实也蛮熟悉,就类似于 rememberLazyListState 来定义 LazyListState 或者使用 rememberScrollState 来定义 ScrollState 一样。

如果您需要对状态应用业务规则,或者需要将状态提升到 ViewModel 中,那么可以像这样定义一个类型为 TextFieldState 的变量:

kotlin 复制代码
// ViewModel
val username = TextFieldState()

// Compose
BasicTextField2(state = viewModel.username)

样式

BasicTextField2 的 Material 封装还未准备好( BasicTextField2 位于 foundations 层)。您可以使用 TextStyle 块来修改 colorfontSizelineHeight 等样式,通过使用各种 Modifier,特别是 border 来实现上面例子中的样式。或者可以使用 decorator 参数来进一步自定义 TextField 容器的外观(就像 OutlinedTextFielddecorationBox 中实现的那样)。

kotlin 复制代码
val username = rememberTextFieldState()
BasicTextField2(state = username,
    textStyle = textStyleBodyLarge,
    modifier = modifier.border(...),
    decorator = @Composable {}
)

行限制

新的 API 通过提供类型为 TextFieldLineLimitslineLimits 参数消除了配置单行/多行的不明确性:

kotlin 复制代码
BasicTextField2(
    /***/
    lineLimits = TextFieldLineLimits.SingleLine
)

BasicTextField2(
    /***/
    lineLimits = TextFieldLineLimits.Multiline(5, 10)
)

SingleLine : TextField 始终为单行,忽略换行符,并在文本溢出时水平滚动。

Multiline :定义最小和最大行数。文本开始时的高度为 minHeightInLines ,当达到字段末尾时,文本会换到下一行。随着继续输入,文本会增长到高度为 maxHeightInLines 。如果继续输入超出范围,将纵向滚动。

状态观察

让我们看看如何观察用户名状态并进行一些验证,并对其应用业务上的规则。

API: textAsFlow

假设我们希望在选定的用户名不可用时显示错误信息。

代码可能如下所示:

kotlin 复制代码
// ViewModel

// 将 TextField 状态提升到 ViewModel 中
val username = TextFieldState()

// 观察用户名 TextField 状态的变化
val userNameHasError: StateFlow<Boolean> =
    username.textAsFlow()
        .debounce(500)
        .mapLatest { signUpRepository.isUsernameAvailable(it.toString()) }
        .stateIn(
            scope = viewModelScope,
            started = SharingStarted.WhileSubscribed(5_000),
            initialValue = false
        )
  • 我们定义了一个变量 userNameHasError,它根据对 username 运行的验证结果为 true 或 false。
  • 我们可以使用 snapshotFlow API 来观察 TextFieldState 中可变状态的文本,对于每个新值,我们可以调用异步函数验证。
kotlin 复制代码
snapshotFlow { username.text }
  • 这种使用 snapshotFlow 观察组合状态的方式非常常见,因此 BTF2 API 提供了一个名为 textAsFlow 的方法,它以简单的方式完成相同的操作,您只需调用该扩展函数即可。
  • 我们使用 Flow API 中的 debounce 来等待一段时间后触发验证,给用户一些缓冲时间来完成输入。
  • 然后我们使用 mapLatest 在每次输入新字符时调用验证。最后,stateIn 将此状态转换为 StateFlow

然后在界面上读取 userNameHasError 。如果该状态为 true,则在用户名下方显示一个 Text

kotlin 复制代码
// Compose
if (signUpViewModel.userNameHasError) {
    // 显示错误标签
}

API: forEachTextValue

在每个字符输入上执行异步操作的另一种便捷方法是使用 textAsFlow().collectLatest(block) 并触发异步验证。还有一个名为 forEachTextValue 的扩展函数,它为我们提供了一个挂起函数作用域,以在每个值更改时进行请求。

我们可以重新编写之前的示例,定义一个挂起方法 validateUsername 。然后对于每个新的文本值,我们进行异步请求并设置 userNameHasError 的值。这在 ViewModel 中完成。在 Compose 中,我们定义一个 LaunchedEffect 来观察输入事件,并观察 userNameHasError 状态来修改视图。

代码可能如下所示:

kotlin 复制代码
// ViewModel

var userNameHasError by mutableStateOf(false)
suspend fun validateUsername() {
    username.forEachTextValue {
        userNameHasError = signUpRepository.isUsernameAvailable(it.toString())
    }
}

// Compose

LaunchedEffect(Unit) {
    signUpViewModel.validateUsername()
}

if (signUpViewModel.userNameHasError) {
    // 显示错误标签
    Text(
        text = "用户名不可用,请选择一个不同的用户名。"
    )
}

onValueChange 重写

让我们再看一种观察状态变化的方法。 BTF2 允许以以下方式定义状态:

kotlin 复制代码
var username by rememberSaveable { mutableStateOf("") }

BasicTextField2(
    value = username,
    onValueChange = {
        username = it
    }
)

这与当前 API BasicTextField v1 的形式完全相同。在这两个 API 之间进行过渡时,对于只有一个字符串的简单情况来说,这种形式更为熟悉。你可能会想:这不是还会出现之前提到的相同错误吗(在编辑过程中出现意外的异步延迟),那它有什么改进之处?虽然这个 API 在 BTF2BTF1 中看起来完全一样,但在底层它们的行为非常不同。

kotlin 复制代码
BasicTextField2(
    // 当 BTF2 被 focus 时,下面这一行会被忽略
    value = username,
    onValueChange = {
        // *** 可能存在的异步耗时任务 *** //

        username = it
    }
)

在此API中,无论发生什么,lambda 内部的代码总是会被调用。因此,如果您在 lambda 内部进行异步调用,这些调用将会被执行。然而,在 TextField 获得焦点时,你的修改不会导致更新状态,它只会接受来自用户输入的内容。这会导致,用户在界面上不会看到任何在 lambda 内部进行的更改。该字段始终得到控制,使界面响应快速、并避免了异步问题。只有当 TextField 失去焦点时,您所做的最后一次更改才会被应用。这种内部机制确保了编辑过程中 TextField 状态的完整性,因为它始终保持了一个可信数据源(用户通过软键盘或开发者设置的),在必要的情况下忽略程序的更改。相反,如果您期望您的更改在特定时间点反映在UI上,那么您可能会感到失望。

为避免歧义,此段落附上原文 (Whatever happens inside the lambda is always called, so if you're making async calls those will be fired. However, the text field won't update the state with your programmatic changes while the field is in focus , it respects only the input coming from typing events. The result is that the user won't see any changes applied inside lambda reflected in the UI. The field has the control at all times making it snappy and responsive, avoiding the async issues. Only when your field loses focus, any programatic changes you have done last will be applied. This internal mechanism guarantees the integrity of the text field state during the editing process, because it keeps one source of truth at all times (either user through the software keyboard (IME) or the developer), ignoring programatic changes if it needs to. The counterpart is that you might expect your changes to be reflected in the UI at a certain point in time but they won't be.)

因此,建议只在最简单的情况下使用此 API 形式,例如仅需要一个字符串来表示状态,并且不需要在 TextField 获得焦点时做操作,如修改文本、选区或光标位置等。对于更复杂的情况,请使用我们已经介绍过的带有 TextFieldState 的API形式。

用代码编辑文本

在许多情况下,我们需要或希望手动操作 TextField 的内容。最简单的例子是添加一个清除按钮来删除 TextField 的内容。

为了访问 Edit Session 以对 TextField 内容进行更改,新引入了一个名为 TextFieldBuffer 的 API。

kotlin 复制代码
class TextFieldBuffer : Appendable {

    val length: Int

    val hasSelection: Boolean
    var selectionInChars: TextRange

    fun replace(...)
    fun append(...)
    fun insert(...)
    fun delete(...)

    fun selectAll(...)
    fun selectCharsIn(...)
    fun placeCursorAfterCharAt(...)

}

这个类保存了有关 TextField 的信息,比如长度和选区(如果有),还有一些方法可以显式地修改文本,比如替换、追加、插入和删除,以及更改选择和光标位置。

为了实现清除按钮,我们可以这样写:

kotlin 复制代码
// ViewModel
val username = TextFieldState()

fun clearField() {
    username.edit {
        // 我们在 TextFieldBuffer 的 scope 中
       delete(0, length)
    }
}

注意,我们使用 edit 函数来访问 TextFieldBuffer ,从而可以访问上面描述的所有方法。在上面的例子中,我们删除 TextField State 的内容。这个方法非常常见,所以 BTF2 提供了一个名为 clearText 的扩展函数,可以完全实现相同的功能。所以代码可以简化为:

kotlin 复制代码
// ViewModel
val username = TextFieldState()

fun clearField() {
    username.clearText()
}

另一个例子,假设您有一个 Markdown 文本编辑器(例如 Github 的 PR 描述),您想要在选择的文本周围添加 "**" 字符,以表示该文本是粗体的:

kotlin 复制代码
// Compose

val markdownText = rememberTextFieldState()
BasicTextField2(
    state = markdownText
)

// ...

Button(
    onClick = {
        markdownText.edit {
            if (selectionInChars.length > 0) {
                insert(selectionInChars.start, "**")
                insert(selectionInChars.end, "**")
            }
        }
    },
) {
    Text(text = "B", fontWeight = Bold)
}

有关如何为 TextFieldBuffer 编写 Markdown 编辑器的进一步示例,请参见 此代码片段

或者在出现错误时选择所有文本:

kotlin 复制代码
// ViewModel
val username = TextFieldState()

val userNameHasError: StateFlow<Boolean> =
    username.textAsFlow()
        .mapLatest {
            val hasError = signUpRepository.isUsernameAvailable(it.toString())
            if (hasError) highlight()
            return@mapLatest hasError
        }
        .stateIn(...)

fun highlight() {
    username.edit { selectAll() }
}

此时,您可能会意识到这种方式命令 UI 进行文本 edit 与声明式 UI 的范例相悖。确实。为了防止状态同步问题,TextField 的状态更新完全交给内部组件处理,没有真正的状态观察来更新 UI。这是 TextField 的设计选择,就像在 ScrollableState 中的 animateScrollTo 方法一样,您可以命令 UI 做某事。

过滤 | 转换输入

假设我们要实现一个接受数字验证码的 TextField:

为了过滤用户的输入,例如仅接受数字或者去掉特殊字符,你需要定义一个 InputTransformation 。这将在保存到 TextField 状态之前修改用户输入。这是一个不可逆的操作,所以你会丢失不符合你转换规则的输入。这就是为什么我们把它们称为"过滤器"。

InputTransformation API 的形式如下:

kotlin 复制代码
fun interface InputTransformation {

    val keyboardOptions: KeyboardOptions? get() = null

    fun transformInput(
        originalValue: TextFieldCharSequence, 
        valueWithChanges: TextFieldBuffer
    )    

}

transformInput 方法包含了原始输入文本和带有更改的值,以 TextFieldBuffer 的形式描述,该类在上面的部分中有介绍。 TextFieldBuffer API 提供了一个 ChangeList 类型的更改列表。

kotlin 复制代码
class TextFieldBuffer {

    // 其他字段和方法

    val changes: ChangeList get()

    interface ChangeList {
        val changeCount: Int
        fun getRange(changeIndex: Int): TextRange
        fun getOriginalRange(changeIndex: Int): TextRange
    }

}

你可以对更改做任何操作,包括丢弃它们。

kotlin 复制代码
object DigitsOnlyTransformation : InputTransformation {

    override val keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)

    override fun transformInput(
        originalValue: TextFieldCharSequence,
        valueWithChanges: TextFieldBuffer
    ) {
        if (!valueWithChanges.asCharSequence().isDigitsOnly()) {
            valueWithChanges.revertAllChanges()
        }
    }
}

// Compose

BasicTextField2(
    state = state,
    inputTransformation = DigitsOnlyFilter
)

我们定义了一个实现了 InputTransformation 接口的对象。首先,我们需要实现 transformInput 方法。在我们的例子中,我们检查来自 TextFieldBuffer 的更改。如果它们只包含数字,我们保留这些更改。如果字符不是数字,我们撤销这些更改。这非常简单,因为差异是由内部为我们处理的。 请注意,我们还设置了相应的键盘类型为 Number

由于 InputTransformation 是一个函数式接口,你可以直接向 BasicTextField2 组合中传递 lambda 以描述你的转换,如下所示:

kotlin 复制代码
BasicTextField2(
    state = state,
    inputTransformation = { originalValue, valueWithChanges ->
        if (!valueWithChanges.asCharSequence().isDigitsOnly()) {
            valueWithChanges.revertAllChanges()
        }
    },
    // in this case pass the keyboardOptions to the BFT2 directly, which
    // overrides the one from the inputTransformation
    keyboardOptions = KeyboardOptions.Default.copy(keyboardType = KeyboardType.Number
)

如果你只有一个需要特定 Transformation 的 TextField ,那么这样做就可以了;如果有多个 TextField 需要这种特定的转换,那么提取成 object 更好。

接下来,我们需要构建一个最大长度为 6 个字符且全部大写的验证码字段。

对于这种常见情况,我们有一些内置的输入转换: maxLengthInChars 用于限制字段长度, allCaps 用于将文本转换为大写。 我们可以这样编写验证码:

kotlin 复制代码
BasicTextField2(
    state = state,
    inputTransformation = InputTransformation.maxLengthInChars(6)
        .then(InputTransformation.allCaps(Locale.current)),
    )

我们使用 then 来链接输入转换,过滤器按顺序依次应用。

Visual transformation | OutputTransformation ⚠️

截至 2023 年 11 月初:⚠️ OutputTransformation API 正在建设中。您可以在 此处 查看进展。

在我们的验证码 TextField 示例中,现在我们希望用点替换用户尚未输入的字符,并将它们分组成三个一组,在其中添加一个空格,如下所示:

为了解决这个问题以及其他需要格式化 TextField 内容的情况(比如格式化电话号码或信用卡号)您可以定义一个 OutputTransformation 。它将在显示 UI 时格式化内部状态。请注意,与 InputTransformation 不同,应用 OutputTransformation 的结果不会保存到 TextField 状态中。

OutputTransformation API 的形式如下:

kotlin 复制代码
fun interface OutputTransformation {
	fun transformOutput(buffer: TextFieldBuffer)
}

新 API 的一个巨大好处是,我们无需提供原始原始文本和转换后文本之间的偏移映射。 TextField 会隐式地为我们处理这一点。 在这个示例中,我们定义一个实现了 OutputTransformation 接口的对象,并实现 transformOutput

kotlin 复制代码
object VerificationCodeOutputTransformation : OutputTransformation {
    override fun transformOutput(buffer: TextFieldBuffer) {
        // 如果长度不足,则使用占位符字符填充文本
        // ··· ···
        val padCount = 6 - buffer.length
        repeat(padCount) {
            buffer.append('·')
        }

        // 123 456
        if (buffer.length > 3) buffer.insert(3, " ")
    }
}

首先,我们在 TextField Buffer上调用 append 方法,为任何尚未输入的字符插入"点"。然后我们调用 insert 方法在中间添加一个空格。完事儿,没有旧 API 导致的混乱和崩溃。很不错,对吧?

SecureTextField

让我们在注册界面中加上 Password Field 。编写 Password Field 是一个非常常见的用例,Jetpack Compose 为此专门提供了一个新的组合控件,基于 BasicTextField2 构建的 BasicSecureTextField

kotlin 复制代码
val password = rememberTextFieldState()
BasicSecureTextField(
    state = password,
    textObfuscationMode = TextObfuscationMode.RevealLastTyped
)

textObfuscationMode 有 3 种有用的模式。 RevealLastTyped 是默认模式,与在 View 中将输入类型配置为 textPassword 时的 EditText 的行为相匹配。采用这种行为,您可以在超时之前或输入下一个字符之前短暂地看到最后一个输入的字符。然后是 Hidden ,在这种情况下,您永远看不到输入的字符,还有 Visible ,用于暂时使密码值可见。

BasicSecureTextField 作为一个独立的组合控件非常强大。它允许团队在内部进行安全优化,确保字段内容在内存中不会比应该保存的时间更长,避免诸如内存欺骗之类的问题。它具有自带小眼睛的 UI 和 textObfuscationModes ,以及与之关联的明确行为,例如对 Text Toolbar 的修改(您无法剪切或复制 Password Field 的内容)。


您无法剪切或复制 Password Field 的内容。

还有更多...

有很多东西可供讨论,但我将只在这儿说其中的3个亮点。

新的 BasicTextField2 允许您访问内部滚动状态。像对其他可滚动组合一样(如 LazyLayout ),将滚动状态提升,然后将其传递给 BasicTextField2 ,现在您可以将另一个 Composable(例如 Vertical Slider ) 作为 TextField 的滚动条,以用代码控制滚动:

kotlin 复制代码
val scrollState = rememberScrollState()

BasicTextField2(
    state = state,
    scrollState = scrollState,
    // ...
)

Slider(
    value = scrollState.value.toFloat(),
    onValueChange = {
        coroutineScope.launch { scrollState.scrollTo(it.roundToInt()) }
    },
    valueRange = 0f..scrollState.maxValue.toFloat()
)


控制 TextField 的滚动

团队增加了对更多手势的支持,例如双击选择单词。

最后, TextFieldState 为您提供了对 UndoState 类的访问。这个类保存状态的历史值,并提供了 undoredo 编辑更改的有用方法,建立在 TextFieldBufferChangeList 之上。您可以用非常少的代码实现撤销/重做支持,就像这样:

kotlin 复制代码
val state: TextFieldState = rememberTextFieldState()

Button(
    onClick = { state.undoState.undo() },
    enabled = state.undoState.canUndo
) {
    Text("撤销")
}

Button(
    onClick = { state.undoState.clearHistory() },
    enabled = state.undoState.canUndo || state.undoState.canRedo
) {
	Text("清除历史记录")
}

非常强大的 API,而且一切都是开箱即用的 😊🎉

欲了解更多,请查看 Zach 的演讲,他是该项目的主要工程师之一,负责谷歌的 Compose Text 项目,讲述了这个项目的起源以及当前的进展情况。

重新构想 Compose 中的 TextField

我们已经看到如何:

  • 使用 InputTransformation 应用过滤器
  • 使用 OutputTransformation 进行视觉转换
  • BasicSecureTextField 编写 Password Field

新的 API 结构还将允许进行一些当前不可能的操作,比如识别编辑过程中的显式更改并访问 TextField 的滚动状态。

未来

BasicTextField2 目前正在建设中。欢迎在最新的 Compose 1.6.0 中尝试这个 API,并向团队提供反馈。思考一下您正在处理的复杂编辑用例,您还希望 API 支持什么,以及当前的结构是否能够满足您的需求。

在尝试之后,您可以提供反馈!🙏

BasicTextField2 在一个名为 text2 的单独包中,因此它是明确可区分的。这只是一个临时包, BasicTextField2 是一个临时名称,API 正在稳定化过程中。

接下来,团队即将完成 OutputTransformation API。此外,多文本样式编辑(Multi Text Style Editing)是一个备受期待的功能,而使用这种新的 API 结构使其变得可能。当然还有 Material 封装,以便它可以像任何其他 Material 可组合一样正常工作,并具有正确的样式。

敬请关注 BasicTextField2 更多的发布信息。

您可以在 Github playground repo 中找到本文中使用的所有代码。


原文结束

译者:我还没有实际用过 BasicTextField2,但看起来不错,不过感觉还是有一些迁移工作要做。目前,Jetpack Compose 1.6 仍然没有发布正式版,我也在持续关注它的更新。当它发布时,我会尽快写一篇文章介绍的。感兴趣的欢迎关注,下次见 ~

相关推荐
Winston Wood几秒前
一文了解Android中的AudioFlinger
android·音频
B.-2 小时前
Flutter 应用在真机上调试的流程
android·flutter·ios·xcode·android-studio
有趣的杰克2 小时前
Flutter【04】高性能表单架构设计
android·flutter·dart
大耳猫7 小时前
主动测量View的宽高
android·ui
帅次10 小时前
Android CoordinatorLayout:打造高效交互界面的利器
android·gradle·android studio·rxjava·android jetpack·androidx·appcompat
枯骨成佛11 小时前
Android中Crash Debug技巧
android
kim565916 小时前
android studio 更改gradle版本方法(备忘)
android·ide·gradle·android studio
咸芝麻鱼16 小时前
Android Studio | 最新版本配置要求高,JDK运行环境不适配,导致无法启动App
android·ide·android studio
无所谓จุ๊บ16 小时前
Android Studio使用c++编写
android·c++
csucoderlee16 小时前
Android Studio的新界面New UI,怎么切换回老界面
android·ui·android studio