本文介绍: 除了官方适配方案,也许我们还需更灵活的自定义适配方案,那么首先第一件就是我们需要知道如何识别折叠屏。兼容的 Letterboxing 模式表现等配置不同行为Compose /Activity Embedding /SlidingPaneLayout适配方案折叠屏的判断窗口适配生命周期兼容我相信还有很多的 App 没有计划折叠屏做适配,毕竟「又不是不能用」,但是了解完本篇,至少可以给你提供一些底气,至少看起来如果真要适配,也不是什么做不到的事情。

自 2019 年三星发布了第一台(柔宇不算) Galaxy Z Fold 之后,Android 厂商们都陆续跟进了各自的可折叠方案,之后折叠手机市场一直保持快速增长例如 2023 年上半年整体销量 227 万台,同比增长 102.0%。

虽然对比上半年手机总体出货量 1.3 亿台只能算是零头,但是不可否认,如今开发者的 App 遇到可折叠手机的概率并不低,特别这部分用户概率属于「高产值」用户。

所以 2023 年开始,折叠屏适配也逐步开始成为 Android 的主流 KPI 之一,那么不适配的话会怎么样?适配的话又是通过什么方式本篇将带你深入了解这个话题。

⚠️本文超长,可收藏以备不时之需。

Letterboxing 模式

首先,如果不适配的话,你的应用概率不一定)会是 Letterboxing 模式显示方式可能你会看到 App 以如下图所示方式存在,也就是应用宽高比和屏幕比例不兼容时,App 可能会以 Letterbox 模式打开

一般是 App 锁死旋转方向采用不可调整大小

当然,是否进入 Letterboxing 模式和 TargetSDK 版本、 App 配置屏幕分辨率都有关系,并且不同 OS 版本上 Letterboxing 模式呈现方式可能有所不同例如

那么什么时候进入 Letterboxing 模式 ?一般可以简单理解为:

这里核心点其实resizeableActivity ,它用于声明系统是否可以调节 App 大小适应不同尺寸屏幕其实严格来说 resizeableActivity 不一定会导致应用一定进入 Letterboxing 模式,这也 API 版本关系

sw >= 600dp 可以简单理解为你的屏幕的绝对宽度大于 600dp

那有的人就说了,如果我在 Android 12 就使用 android:resizeableActivity=false 然后什么都不适配会怎么样?我只能说,「有一定概率」会如下图所示一样,直接 crash

那是不是我不使用版本的 TargetSDK 就可以不用工作适配了呢?

也不完全是,至少你需要对你的 App 或者 Activity 进行一些简单的配置,因为早在 Android 7.0(API 24)开始,resizeableActivity默认值就被改为 true

所以如果你不想适配大屏模式 UI,希望进入 Letterboxing 模式,还是需要手动在 AndroidManifest 中的 application对应Activity 配置上 android:resizeableActivity="false"

另外,Letterboxing 模式的显示模式和 maxAspectRatio 也有关,当屏幕比例超过 maxAspectRatio 时才会用黑边填充,一般官方建议maxAspectRatio 设为 2.4 (12 : 5),配置方式也和 API Level关系

PS :如果 resizeableActivitytruemaxAspectRatio 会不生效

图是前面提到 Android 12L(API 32)的重启按钮可以让 App 一端尽可能适配屏幕减少黑边。

还有一点,在折叠屏展开和闭合的时候,在屏幕发生了变化时,系统可能销毁并重新创建整个 Activity ,所以我们需要配置 android:configChanges 来防止重启

android:configChanges="screenLayout|smallestScreenSize|screenSize"

最后需要注意 supports_size_changes ,如果不想支持多窗口模式,但是又可能会因为系统强迫进入多窗口模式,然后又不希望每次都被重启,那么可以配置 supports_size_changes 来保证运行的连续性。

<meta-data
    android:name="android.supports_size_changes" android:value="true" />

所以这里简单做个总结就是

官方适配支持

接下来就是介绍适配方案,首先我们看这张图,其实官方已经根据使用场景我们定义好使用建议,其中关键的几个信息有:

另外,在官方的不同屏幕尺寸匹配里设定了窗户尺寸等级规范例如

  • Compact: 普通手机设备宽度 < 600dp
  • Medium:折叠屏或平板的竖屏,600dp < 宽度 < 840dp
  • Expanded:展开屏幕,平板或平板电脑等,宽度 > 840dp

当然还有基于高度判断的,但是大多数 App 可以通过仅考虑宽度窗口大小类别构建响应式 UI

Compose

其实 Compose 不必多说,在折叠屏适配上响应布局本身就具有先天优势,配合 Jetpack WindowManager API 提供的当前的屏幕参数,就可以很灵活地达到适配不同 UI 效果

例如 Compose 可以使用 material3-window-size-class 库,然后利用 calculateWindowSizeClass() 计算当前窗口WindowSizeClass ,从而改变 UI 的布局

import androidx.activity.compose.setContent
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass

class MyActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent {
            // Calculate the window size class for the activity's current window. If the window
            // size changes, for example when the device is rotated, the value returned by
            // calculateSizeClass will also change.
            val windowSizeClass = calculateWindowSizeClass(this)
            // Perform logic on the window size class to decide whether to use a nav rail.
            val useNavRail = windowSizeClass.widthSizeClass > WindowWidthSizeClass.Compact

            // MyScreen knows nothing about window size classes, and performs logic based on a
            // Boolean flag.
            MyScreen(useNavRail = useNavRail)
        }
    }
}

另外还可以通过 com.google.accompanist:accompanist-adaptiveTwoPane 进行适配

TwoPane 提供了两个固定的槽位,两个槽位的默认位置TwoPaneStrategy 驱动,它可以决定将两个槽位水平或垂直排列,并可配置它们之间间隔

更多可见:https://github.com/google/accompanist/tree/3810fe1182cf52c6660787ae3226dfb7f5ad372a/sample/src/main/java/com/google/accompanist/sample/adaptive

不同场景 Compose 还可以使用 FlowLayout 适配折叠变化 ,FlowLayout 包含 FlowRowFlowColumn ,当一行(或一列)放不下里边的内容时,会自动换行,这在折叠屏展开和收缩场景也非常实用。

关于 Compose 适配折叠屏 Demo 还可以参考https://github.com/android/composesamples/tree/main/JetNews

Activity Embedding

Activity Embedding 就是通过在两个 Activity 或同一 Activity 的两个实例之间拆分窗口,来优化大屏幕的支持。

理论上 Activity Embedding 不需要代码重构,可以通过创建 XML 配置文件或进行 Jetpack WindowManager API 调用确定 App 如何显示其 Activity(并排或堆叠

Activity Embedding 默认自动维护对小屏幕的支持,当应用位于小屏幕设备上时,Activity 会一个一个地堆叠在另一个之上;在大屏幕上,Activity 会展开并排显示。

这个基础上,它可以适应设备方向的变化,并在可折叠设备上无缝工作,在设备折叠或展开时堆叠被拆开的 Activity,例如在聊天列表聊天详情页面进行拆分堆叠

无论是 Android 12L(API 32)以上的大屏设备,还是更早期折叠屏平台版本的设备,Jetpack WindowManager 都能帮助构建 Activity Embedding 多窗格布局,这种基于多个 Activity 而非 fragment基于视图布局(如 SlidingPaneLayout)的方式可以最简单提供大屏幕用户体验而无需重构源代码

一个常见示例列表-详情分屏,为了确保高质量呈现,系统先启动列表 Activity,然后应用立即启动详情 Activity,过渡系统等到这两个 Activity 都绘制完成后再将它们一起显示出来,对用户来说,这两个 Activity 是作为一个页面启动

目前大多数运行 Android 12L(API 32)及更高版本的大屏幕设备都支持 Activity Embedding。

使用 Jetpack WindowManager 管理和配置 Activity Embedding 其实相当灵活,可以预先配置 XML 规则,或者直接通过 API 进行管理配置,对于 XML 配置文件定义规则设置以下属性

可以看到 Jetpack WindowManager 十分丰富且灵活的配置支持,而不是单纯简单的对 Activity 进行平均分割,甚至你还可以配置一个空白 Placeholder 来进行占位显示。

使用 Activity Embedding 你需要依赖 implementation 'androidx.window:window:xxx' ,然后将该 android.window.PROPERTY_ACTIVITY_EMBEDDING_SPLITS_ENABLED 属性添加到应用清单文件<application> 中,并将值设置为 true

<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application>
        <property
            android:name="android.window.PROPERTY_ACTIVITY_EMBEDDING_SPLITS_ENABLED"
            android:value="true" />
    </application>
</manifest>

之后就可以通过 xml 创建各种 Split Rule 或者 WindowManager API 创建 Split Rule 然后调用

<!-- main_split_config.xml -->

<resources
    xmlns:window="http://schemas.android.com/apk/res-auto">

    <!-- Define a split for the named activities. -->
    <SplitPairRule
        window:splitRatio="0.33"
        window:splitLayoutDirection="locale"
        window:splitMinWidthDp="840"
        window:splitMaxAspectRatioInPortrait="alwaysAllow"
        window:finishPrimaryWithSecondary="never"
        window:finishSecondaryWithPrimary="always"
        window:clearTop="false">
        <SplitPairFilter
            window:primaryActivityName=".ListActivity"
            window:secondaryActivityName=".DetailActivity"/>
    </SplitPairRule>

    <!-- Specify a placeholder for the secondary container when content is
         not available. -->
    <SplitPlaceholderRule
        window:placeholderActivityName=".PlaceholderActivity"
        window:splitRatio="0.33"
        window:splitLayoutDirection="locale"
        window:splitMinWidthDp="840"
        window:splitMaxAspectRatioInPortrait="alwaysAllow"
        window:stickyPlaceholder="false">
        <ActivityFilter
            window:activityName=".ListActivity"/>
    </SplitPlaceholderRule>

    <!-- Define activities that should never be part of a split. Note: Takes
         precedence over other split rules for the activity named in the
         rule. -->
    <ActivityRule
        window:alwaysExpand="true">
        <ActivityFilter
            window:activityName=".ExpandedActivity"/>
    </ActivityRule>

</resources>

更多可见:https://developer.android.com/guide/topics/large-screens/activity-embedding

SlidingPaneLayout

SlidingPaneLayout 支持在大屏幕设备并排显示两个窗格,同时还会自动进行调整,在手机等小屏幕设备只显示一个窗格,所以在可折叠场景下也十分实用。

SlidingPaneLayout 会根据两个窗格的宽度来确定是否并排显示这些窗格,例如:

如果视图没有重叠,那么 SlidingPaneLayout 支持对子视图使用布局参数 layout_weight,以指定在测量结束如何划分剩余的空间

例如这个例子使用了 SlidingPaneLayout布局将 RecyclerView 作为其左侧窗格,将 FragmentContainerView 作为其主要详细信息视图,用于显示左侧窗格中的内容其实就类似前面介绍的在 Compose 里使用 TwoPane 的 UI。

<!-- two_pane.xml -->
<androidx.slidingpanelayout.widget.SlidingPaneLayout
   xmlns:android="http://schemas.android.com/apk/res/android"
   android:id="@+id/sliding_pane_layout"
   android:layout_width="match_parent"
   android:layout_height="match_parent">

   <!-- The first child view becomes the left pane. When the combined
        desired width (expressed using android:layout_width) would
        not fit on-screen at once, the right pane is permitted to
        overlap the left. -->
   <androidx.recyclerview.widget.RecyclerView
             android:id="@+id/list_pane"
             android:layout_width="280dp"
             android:layout_height="match_parent"
             android:layout_gravity="start"/>

   <!-- The second child becomes the right (content) pane. In this
        example, android:layout_weight is used to expand this detail pane
        to consume leftover available space when the
        the entire window is wide enough to fit both the left and right pane.-->
   <androidx.fragment.app.FragmentContainerView
       android:id="@+id/detail_container"
       android:layout_width="300dp"
       android:layout_weight="1"
       android:layout_height="match_parent"
       android:background="#ff333333"
       android:name="com.example.SelectAnItemFragment" />
</androidx.slidingpanelayout.widget.SlidingPaneLayout>

另外 SlidingPaneLayout 还可以和 Navigation 配合管理 Fragment 事物,并且它现在还会识别和适应折叠和铰链状态,例如:

使用的设备带有遮挡部分屏幕的铰链,它会自动将 App 的内容放置在任一侧。

SlidingPaneLayout引入锁定模式,支持在窗格重叠控制滑动行为,例如:

为了防止用户滑到空窗格,需要点击击列表项才能加载有关该窗格的信息,但允许他们滑回到列表,在有空间并排显示两个视图的可折叠设备或平板电脑上,锁定模式将被忽略

更多可见: https://developer.android.com/guide/topics/ui/layout/twopane?hl=zh-cn

自定义适配

除了官方的适配方案,也许我们还需更灵活的自定义适配方案,那么首先第一件就是我们需要知道如何识别折叠屏。

识别折叠屏

还是前面提到的 Jetpack WindowManager ,Jetpack WindowManager 的 FoldingFeature 提供了有关可折叠显示器信息类型,包括:

在 Android 11 官方还提供了读取折叠角度的支持:新增的类型 TYPE_HINGE_ANGLE 支持以及新的 SensorEventSensorEvent 可以监控合页角度,并提供设备的两部分之间角度测量值:

sensorManager = getSystemService(Context.SENSOR_SERVICE) as SensorManager
        hingeAngleSensor = sensorManager?.getDefaultSensor(Sensor.TYPE_HINGE_ANGLE)

关于折叠屏的姿态我们可以通过 Jetpack WindowManager 的 API 来实现

  • 设备处于 TableTop 模式,屏幕半开并且铰链处于水平方

    fun isTableTopMode(foldFeature: FoldingFeature) =
      foldFeature.isSeparating &amp;&amp;
              foldFeature.orientation == FoldingFeature.Orientation.HORIZONTAL
    

  • 设备处于 Book 模式,屏幕半开并且铰链处于垂直方向

    fun isBookMode(foldFeature: FoldingFeature) =
      foldFeature.isSeparating &amp;&amp;
              foldFeature.orientation == FoldingFeature.Orientation.VERTICAL
    

例如 Google Duo team 就通过 Jetpack WindowManager 识别折叠屏状态,然后根据展开状态在播放过程调整界面 UI。

简单介绍一下,就是初始化时通过 WindowManager 库获取 Flow<WindowLayoutInfo> ,让手机知道目前处于桌面模式以及如何获取折叠的位置

    override fun onStart() {
        super.onStart()
        initializePlayer()
        layoutUpdatesJob = uiScope.launch {
            windowInfoRepository.windowLayoutInfo
                .collect { newLayoutInfo ->
                    onLayoutInfoChanged(newLayoutInfo)
                }
        }
    }

    override fun onStop() {
        super.onStop()
        layoutUpdatesJob?.cancel()
        releasePlayer()
    }

每次获得新的布局信息时,都可以查询显示功能检查设备在当前显示中是否有折叠或铰链:

private fun onLayoutInfoChanged(newLayoutInfo: WindowLayoutInfo) {
        if (newLayoutInfo.displayFeatures.isEmpty()) {
            // The display doesn't have a display feature, we may be on a secondary,
            // non foldable-screen, or on the main foldable screen but in a split-view.
            centerPlayer()
        } else {
            newLayoutInfo.displayFeatures.filterIsInstance(FoldingFeature::class.java)
                .firstOrNull { feature -> isInTabletopMode(feature) }
                ?.let { foldingFeature ->
                    val fold = foldPosition(binding.root, foldingFeature)
                    foldPlayer(fold)
                } ?: run {
                centerPlayer()
            }
        }
    }

如果方向为水平且 FoldingFeature.isSeparating() 返回 true,则设备可以在桌面模式下使用,在这种情况下,可以计算折叠的相对位置并将控件移动对应位置,否则将其移动到 0(屏幕底部)。

    private fun centerPlayer() {
        ConstraintLayout.getSharedValues().fireNewValue(R.id.fold, 0)
        binding.playerView.useController = true // use embedded controls
    }

    private fun foldPlayer(fold: Int) {
        ConstraintLayout.getSharedValues().fireNewValue(R.id.fold, fold)
        binding.playerView.useController = false // use custom controls
    }

窗口大小适配

折叠设备的适配里,窗口大小获取也是非常重要的一点,但是其实 Android 发展至今,其中一些 API 已经被弃用,或者说还在被误用,针对大屏幕设配的适配上,因为有 Letterboxing 等情况,所以其实旧的 API 已经无法满足需求

目前已弃用且经常被误用的 Display API 有:

  • getMetrics()
  • getSize()
  • getRealMetrics()
  • getRealSize()
  • getRectSize()
  • getWidth()
  • getHeight()

经常被误用的 View API 有:

  • getWindowVisibleDisplayFrame()
  • getLocationOnScreen

例如 Display getSize() getMetrics() 在 API 30 中已经被弃用,取而代之的是新 WindowManager方法

Android 12(API 31)弃用了 DisplaygetRealSize()getRealMetrics()更新的还有与之相关getMaximumWindowMetrics() 方法

因为折叠屏和多屏幕下,你的 App 实际尺寸和屏幕实际尺寸之间并不一定一致,所以不能依赖物理显示尺寸来定位 UI 元素,现在推荐依赖于 WindowMetrics 的 API :

  • Platform
    • getCurrentWindowMetrics()
    • getMaximumWindowMetrics()
  • Jetpack
    • WindowMetricsCalculator#computeCurrentWindowMetrics()
    • WindowMetricsCalculator#computeMaximumWindowMetrics()

这里的 Platform 是 Android 11(API 30)引入了 WindowManager 方法来提供在多窗口模式下运行的应用的边界

  • getCurrentWindowMetrics()返回系统当前窗口状态对象 WindowMetrics
  • getMaximumWindowMetrics()返回系统的最大窗口状态 WindowMetrics

Jetpack WindowManager 库方法 computeCurrentWindowMetrics()computeMaximumWindowMetrics() 分别提供类似的功能,但向后兼容到 API 14。

val windowMetrics = context.createDisplayContext(display)
                    .createWindowContext(WindowManager.LayoutParams.TYPE_APPLICATION, null)
                    .getSystemService(WindowManager::class.java)
                    .maximumWindowMetrics

所以,通过 WindowManager我们可以动态去管理窗口的大小变化,识别折叠屏的变化状体,例如在onConfigurationChanged()来配置当前窗口大小的应用布局:

override fun onConfigurationChanged(newConfig: Configuration) {
    super.onConfigurationChanged(newConfig)
    val windowMetrics = WindowMetricsCalculator.getOrCreate()
        .computeCurrentWindowMetrics(this@MainActivity)
    val bounds = windowMetrics.getBounds()
    ...
}

最后,在窗口自定义适配上,就是老生常谈的话题了,例如:

  • 使用 wrap_contentmatch_parent 避免硬编码
  • 使用 ConstraintLayout 做根布局,方便屏幕尺寸变化,视图自动移动拉伸
  • 在 App 的 AndroidManifest 里将 applicationactivityandroid:resizeableActivity 属性设置true 来支持大小调整并支持响应式/自适应布局。
  • res/layout/ 可以通过创建如 layout-w600dp 的等目录来提供自适应的布局
  • ·····

多窗口生命周期

既然折叠屏纯在多个区域,就可能存在多窗口,甚至不止两个窗口,这种情况下自然而然就存在生命周期适配的问题,例如多个 App 同时访问 Camera 。

关于多窗口的进程,可以简单介绍下:

  • Android 7.0 支持分屏:左右/上下显示两个窗口

  • Android 8.0 支持画中画模式,此时处于画中画Activity 虽处于前台,但处于 Paused 状态

  • Android 9.0 (API 28) 及以下:多窗口下只有获得焦点应用处于 Resumed 状态,其它可见 Activity 仍处于 Paused 状态

  • Android 10.0 (API 29) :多窗口模式时,每个 Acttivity 全部处于Resumed状态

看到没有,不同 API 级别下居然生命周期都不一样,所以为解决 Android 9.0 及以下只有获得焦点应用才处于 Resume 状态问题,App 端可添加下列属性手动添加开启支持多项 Resumed

<meta-data
    android:name="android.allow_multiple_resumed_activities" android:value="true" />

就是俗称的 Multi-resume 状态。

为了支持 Multi-resume 状态, 自然就需要一个新的生命周期回调 ,那就是 onTopResumedActivityChanged()

当 Activity 获得或失去顶部 Resume 位置时,系统会调用方法,例如使用共享单例资源(例如麦克风摄像头)时:

override fun onTopResumedActivityChanged(topResumed: Boolean) {
    if (topResumed) {
        // Top resumed activity
        // Can be a signal to re-acquire exclusive resources
    } else {
        // No longer the top resumed activity
    }
}

比如对于使用相机场景,针对上述封装,在 Android 10(API 级别 29)通过CameraManager.AvailabilityCallback#onCameraAccessPrioritiesChanged() 提供了一个回调提示,表明现在可能是可以尝试访问相机的时机。

这里需要注意的是,使用 resizeableActivity=false 并不能保证独占相机访问权限,因为使用相机的其他 App 可能会在多方显示器打开分屏)。

所以需要 App 在收到 CameraDevice.StateCallback#onDisconnected() 回调处理相关行为,如果 onDisconnected 之后还操作 API,系统就会抛出 CameraAccessException.

事实上只要通过回调做好判断其实这个「焦点」切换体验无缝的。

在多窗口模式下,Android 可能会禁用忽略不适用于与其他 Activity 或应用共享设备屏幕的 Activity 的功能

另外,Activity 也提供了一些方法来支持多窗口模式:

  • isInMultiWindowMode() 是否处于多窗口模式。

  • isInPictureInPictureMode() Activity 是否处于画中画模式。

    注意:画中画模式是多窗口模式的特例,如果isInPictureInPictureMode() 返回 true,则 isInMultiWindowMode() 也会返回 true。

  • onMultiWindowModeChanged() Activity 进入或退出多窗口模式时,系统都会调用方法

    如果 Activity 正在进入多窗口模式,则系统向该方法传递一个值 true;如果 Activity 正在离开多窗口模式,则系统向该方法传递一个值 false。

  • onPictureInPictureModeChanged() Activity 进入或退出画中画模式时,系统都会调用此方法。

    如果 Activity 正在进入画中画模式,则系统向该方法传递一个 true 值;如果 Activity 正在离开画中画模式,则系统向该方法传递一个 false 值。

Fragment 同样提供了类似方式,如 Fragment.onMultiWindowModeChanged()

Flutter

3.13 开始 Flutter 也添加了一个新的 API 来匹配显示器的各种属性 #41685,其中新的 FlutterView.display 返回一个 Display 对象,Display 对象报告显示器物理尺寸、设备像素比和刷新率:

  
  void didChangeMetrics() {
    final ui.Display? display = _display;
    if (display == null) {
      return;
    }
    if (display.size.width / display.devicePixelRatio < kOrientationLockBreakpoint) {
      SystemChrome.setPreferredOrientations(<DeviceOrientation>[
        DeviceOrientation.portraitUp,
      ]);
    } else {
      SystemChrome.setPreferredOrientations(<DeviceOrientation>[]);
    }
  }

这个新 API 的主要目的,是前面提到过的内容因为如果一旦进入了 Letterboxing 模式, Flutter 的 MediaQuery 可能就会无法获取完整avalalbe 屏幕尺寸,所以新的 API 就是提供折叠变化后的真实尺寸给开发者适配的空间。

另外,Flutter 上关于支持多个显示器尺寸的支持还在同步 #125938#125939感兴趣的也可以关注一下。

最后

看到这里的都是很有耐心的同志,本次调研的涉及的内容较多,覆盖知识点也有点广,有的可能不够深入,大体还是提供了方向和思路,主要涉及:

  • 兼容的 Letterboxing 模式表现
  • resizeableActivity 等配置的不同行为
  • Compose /Activity Embedding /SlidingPaneLayout 的适配方案
  • 折叠屏的判断、窗口适配和生命周期兼容
  • Flutter API

我相信还有很多的 App 没有计划对折叠屏做适配,毕竟「又不是不能用」,但是了解完本篇,至少可以给你提供一些底气,至少看起来如果真要适配,也不是什么做不到的事情。

如果你还有什么想说的,欢迎留言评论交流

原文地址:https://blog.csdn.net/ZuoYueLiang/article/details/132451593

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任

如若转载,请注明出处:http://www.7code.cn/show_41576.html

如若内容造成侵权/违法违规/事实不符,请联系代码007邮箱:suwngjj01@126.com进行投诉反馈,一经查实,立即删除

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注