当前位置: 首页 > news >正文

如何让 Android 的前端页面像 iOS 一样“优雅”?

如何让Android的前端页面像iOS一样“优雅”?

作者:方英杰(崇之)

最近在调研前端页面适配 Android 端异形屏的方案,调研过程中发现了一些比较有意思的点,本文主要是做一个总结。

一、提出问题

首先,我们需要知道 Android 上的前端适配面临着什么问题。

问题其实很简单,和原生 UI 视图相比,前端页面无法直接获取到设备的部分控件信息,如状态栏高度、导航栏高度等,假如设备是异形屏,还意味着前端不知道挖孔/刘海的位置、尺寸等信息。因此如果前端页面是一个全屏的页面,就会面临着内容会被这些控件遮挡的风险。

同样是移动端设备,iOS 是怎么做的呢?

实际上,早在 iOS 11 时期,Apple 就针对前端推出了一个 safe-area-inset-* 的概念,它作为一组四个环境变量,在 WebKit 中提供。前端可以通过 env() 函数或是 constant() 函数(已废弃)来获取到对应的值,这四个值代表着前端页面在四个方向上需要留出的 Padding,遵循此配置就能保证前端页面不会被其他控件遮挡。下文中我们统一把这个能力称为 Safe Area。

由于 iOS 这项能力支持的早,加上 Apple 新系统的覆盖率一向很高,所以前端的适配推进的很快。以钉钉为例,目前钉钉内的前端页面在 iOS 上的显示效果比起 Android 上要明显好得多。

Android 效果

iOS 效果

那么 Android 这边有没有类似的东西呢?

实际上,在 iOS 推出这个特性之后,其他比较主流的浏览器内核都相继跟进了这个能力。我们知道 Android 上实际使用的是 Chromium 内核来支撑前端内容显示的(原生方案),其从 69 版本开始已经支持了这个能力。而对于像是钉钉使用的 UC 内核,从 3.0 开始也对此做了支持。

既然都已经支持了,为什么 Android 这边还是无法做到对齐 iOS 的效果呢?

首先就是浏览器内核版本限制,我们知道 Android 的 Chromium 内核是打包在一个独立应用里的,可以单独更新,这样做的好处是内核有什么重要的新特性或安全修复可以马上升级上去,不用跟随长周期的系统更新做升级。因此造成了 Chromium 内核在 Android 各个品牌手机上的版本存在比较严重的碎片化问题,也就导致了我们完全无法保证一些系统版本较低的机器上面带的内核足够支持我们想要的能力。

其次就是 Android 系统的这个 Safe Area 的能力有 bug,它提供的值总是会诡异的变成 0,这就导致了虽然浏览器内核早早提供了这项能力,但是存在一定的稳定性问题。

最后就是即使这个能力在 Android 上生效了,最终所展现的效果却不一定是我们想要的。下面这张截图就很好的体现了这一点。在横屏状态下只有左侧有值,而没有给顶部的状态栏区域留出足够的 Padding。

所以为什么明明是在 iOS 上信手拈来的事,到了 Android 上就各种翻车呢,我觉得找到 Android 系统侧是怎么提供 Safe Area 的值很重要。

二、分析问题

下面是 Android 侧对 Safe Area 支持的底层实现分析过程。

我们使用的 WebView 位于 webkit 包下,但实际上这个包在 Android4.4 就已经废弃了,实际的能力是通过委托到 WebViewProvider 来实现的,这点从 WebView 的注释以及方法中的实际调用就能看出来。

// Implementation notes.// The WebView is a thin API class that delegates its public API to a backend WebViewProvider// class instance. WebView extends {@link AbsoluteLayout} for backward compatibility reasons.// Methods are delegated to the provider implementation: all public API methods introduced in this// file are fully delegated, whereas public and protected methods from the View base classes are// only delegated where a specific need exists for them to do so.@Widgetpublic class WebView extends AbsoluteLayout        implements ViewTreeObserver.OnGlobalFocusChangeListener,        ViewGroup.OnHierarchyChangeListener, ViewDebug.HierarchyHandler {  ...     public void loadUrl(@NonNull String url, @NonNull Map<String, String> additionalHttpHeaders) {    checkThread();    mProvider.loadUrl(url, additionalHttpHeaders);  }  ...}

WebViewProvider 的原生实现就是 WebViewChromium,至于在 WebView 中是如何获取到 WebViewChromium 实例的,这块的分析文章还是很多的,感兴趣的同学可以自行检索相关内容。在 WebViewChromium 中我们的 WebView 作为一个参数传入:

/** * This class is the delegate to which WebViewProxy forwards all API calls. * * Most of the actual functionality is implemented by AwContents (or WebContents within * it). This class also contains WebView-specific APIs that require the creation of other * adapters (otherwise org.chromium.content would depend on the webview.chromium package) * and a small set of no-op deprecated APIs. */@SuppressWarnings("deprecation")@Lifetime.WebViewclass WebViewChromium        implements WebViewProvider,                WebViewProvider.ScrollDelegate,                WebViewProvider.ViewDelegate,                SmartClipProvider {    public WebViewChromium(            WebViewChromiumFactoryProvider factory,            WebView webView,            WebView.PrivateAccess webViewPrivate,            boolean shouldDisableThreadChecking) {        ...    }}

传入的 WebView 将在后续的流程中起到非常重要的作用。

在 WebViewChromium 的初始化阶段,会构造了一个 AwContents 对象,接着传递我们的 WebView:

private void initForReal() {  ...  mAwContents = new AwContents(                  browserContext,                  mWebView,                  mContext,                  new InternalAccessAdapter(),                  new WebViewNativeDrawFunctorFactory(),                  mContentsClientAdapter,                  mWebSettings.getAwSettings(),                  new AwContents.DependencyFactory());  ...}

这个类和我们当前要探讨的内容关联不大,但是在整个 WebView 能力实现过程中起到了至关重要的作用。在 AwContents 中,和 Safe Area 有关联的是下面这段代码:

public AwContents(        AwBrowserContext browserContext,        ViewGroup containerView,        Context context,        InternalAccessDelegate internalAccessAdapter,        NativeDrawFunctorFactory nativeDrawFunctorFactory,        AwContentsClient contentsClient,        AwSettings settings,        DependencyFactory dependencyFactory) {  ...  if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P                    && AwFeatureMap.isEnabled(AwFeatures.WEBVIEW_DISPLAY_CUTOUT)) {    mDisplayCutoutController =        new AwDisplayCutoutController(                new AwDisplayCutoutController.Delegate() {                    @Override                    public float getDipScale() {                        WindowAndroid windowAndroid =                                mWindowAndroid.getWindowAndroid();                        return windowAndroid.getDisplay().getDipScale();                    }
                    @Override                    public void setDisplayCutoutSafeArea(                            AwDisplayCutoutController.Insets insets) {                        if (mWebContents == null) return;                        mWebContents.setDisplayCutoutSafeArea(                                insets.toRect(mCachedSafeAreaRect));                    }                },                containerView);  }  ...}

这里构造了一个 AwDisplayCutoutController 对象,这个类的主要职责是管控 Android 上的 DisplayCutout 相关的逻辑,实际上就是挖孔/刘海。我们可以看到在构造对象时,实现了一个 setDisplayCutoutSafeArea 的方法。这个方法名是不是要素察觉。先不急着看方法的实现,我们先看一下方法是在哪里被调用的:

public AwDisplayCutoutController(Delegate delegate, View containerView) {      mDelegate = delegate;      mContainerView = containerView;      registerContainerView(containerView);  }

可以看到在 AwDisplayCutoutController 的构造方法里,除了相关的变量赋值外,还有一个 registerContainerView 的方法调用,入参为我们传入的 WebView,看看这个方法做了什么:

/** * Register a container view to listen to window insets. * * Note that you do not need to register the containerView. * * @param containerView A container View, such as fullscreen view. */public void registerContainerView(View containerView) {    if (DEBUG) Log.i(TAG, "registerContainerView");    // For Android P~R, we set the listener in WebView's constructor.    // Once we set the listener, we will no longer get View#onApplyWindowInsets(WindowInsets).    // If the app sets its own listener after WebView's constructor, then the app can override    // our logic, which seems like a natural behavior.    // For Android S, WebViewChromium can get onApplyWindowInsets(WindowInsets) call, so we do    // not need to set the listener.    // TODO(crbug.com/40699457): do not set listener and plumb WebViewChromium to handle    // onApplyWindowInsets in S and above.    containerView.setOnApplyWindowInsetsListener(            new View.OnApplyWindowInsetsListener() {                @Override                public WindowInsets onApplyWindowInsets(View view, WindowInsets insets) {                    // Ignore if this is not the current container view.                    if (view == mContainerView) {                        return AwDisplayCutoutController.this.onApplyWindowInsets(insets);                    } else {                        if (DEBUG) Log.i(TAG, "Ignoring onApplyWindowInsets on View: " + view);                        return insets;                    }                }            });}

其实,就只是简单的给我们的 WebView 设置了一个 OnApplyWindowInsetsListener 的监听。只是由于这个方法是在构造函数中调用的,所以设置的时机非常早,在此之后如果我们给 WebView 设置自己的 OnApplyWindowInsetsListener 监听就会把这个监听给覆盖掉。

AwDisplayCutoutController 中设置这个监听其实就只是为了触发自己的 onApplyWindowInsets 方法,方法如下:

/** * Call this when window insets are first applied or changed. * * @see View#onApplyWindowInsets(WindowInsets) * @param insets The window (display) insets. */@VisibleForTestingpublic WindowInsets onApplyWindowInsets(final WindowInsets insets) {    if (DEBUG) Log.i(TAG, "onApplyWindowInsets: " + insets.toString());    // TODO(crbug.com/40699457): add a throttling logic.    DisplayCutout cutout = insets.getDisplayCutout();    // DisplayCutout can be null if there is no notch, or layoutInDisplayCutoutMode is DEFAULT    // (before R) or consumed in the parent view.    if (cutout != null) {        Insets displayCutoutInsets =                new Insets(                        cutout.getSafeInsetLeft(),                        cutout.getSafeInsetTop(),                        cutout.getSafeInsetRight(),                        cutout.getSafeInsetBottom());        onApplyWindowInsetsInternal(displayCutoutInsets);    }    return insets;}

可以看到在这个方法中,通过 WindowInsets 拿到了 DisplayCutout,并使用它的值构造了一个 Insets 对象,传给了 onApplyWindowInsetsInternal 方法,如果 WindowInsets 中没有 DisplayCutout,那么是不会接着往下调用的。在 onApplyWindowInsetsInternal 中,将计算后的 Insets 值传给了 Delegate 的 setDisplayCutoutSafeArea 方法:

/** * Call this when window insets are first applied or changed. * * Similar to {@link onApplyWindowInsets(WindowInsets)}, but accepts * Rect as input. * * @param displayCutoutInsets Insets to store left, top, right, bottom insets. */@VisibleForTestingpublic void onApplyWindowInsetsInternal(final Insets displayCutoutInsets) {    float dipScale = mDelegate.getDipScale();    // We only apply this logic when webview is occupying the entire screen.    adjustInsetsForScale(displayCutoutInsets, dipScale);
    if (DEBUG) {        Log.i(                TAG,                "onApplyWindowInsetsInternal. insets: "                        + displayCutoutInsets                        + ", dip scale: "                        + dipScale);    }    // Note that internally we apply this logic only when the display is in fullscreen mode.    // See AwDisplayModeController for more details on how we check the fullscreen mode.    mDelegate.setDisplayCutoutSafeArea(displayCutoutInsets);}

现在我们就回到了 AwContents 类中,setDisplayCutoutSafeArea 的实现如下:

@Overridepublic void setDisplayCutoutSafeArea(        AwDisplayCutoutController.Insets insets) {    if (mWebContents == null) return;    mWebContents.setDisplayCutoutSafeArea(            insets.toRect(mCachedSafeAreaRect));}

实际上是调用了 WebContents 的 setDisplayCutoutSafeArea 方法,这个方法实际上会调用到 C++ 的能力:

@Overridepublic void setDisplayCutoutSafeArea(Rect insets) {    if (mNativeWebContentsAndroid == 0) return;    WebContentsImplJni.get()            .setDisplayCutoutSafeArea(                    mNativeWebContentsAndroid,                    insets.top,                    insets.left,                    insets.bottom,                    insets.right);}

一路跟踪调用链到这里,我们其实已经基本能确定前端拿到的 Safe Area 的值就是从 WindowInsets 的 DisplayCutout 中获取的了。为了进一步验证这个推断,我们可以看一下 C++ 层的实现:

#if BUILDFLAG(IS_ANDROID)
void WebContentsImpl::SetDisplayCutoutSafeArea(gfx::Insets insets) {  OPTIONAL_TRACE_EVENT0("content", "WebContentsImpl::SetDisplayCutoutSafeArea");  if (safe_area_insets_host_) {    safe_area_insets_host_->SetDisplayCutoutSafeArea(insets);  }}
#endif
void DisplayCutoutHostImpl::SetDisplayCutoutSafeArea(gfx::Insets insets) {  insets_ = insets;
  if (current_rfh_)    SendSafeAreaToFrame(current_rfh_.get(), insets);}
void SafeAreaInsetsHost::SendSafeAreaToFrame(RenderFrameHost* rfh,                                             gfx::Insets insets) {  blink::AssociatedInterfaceProvider* provider =      rfh->GetRemoteAssociatedInterfaces();  if (!provider) {    return;  }
  mojo::AssociatedRemote<blink::mojom::DisplayCutoutClient> client;  provider->GetInterface(client.BindNewEndpointAndPassReceiver());  client->SetSafeArea(insets);}
void DisplayCutoutClientImpl::SetSafeArea(const gfx::Insets& safe_area) {  frame_->GetDocument()->GetPage()->SetMaxSafeAreaInsets(frame_, safe_area);}
void Page::SetMaxSafeAreaInsets(LocalFrame* setter, gfx::Insets max_safe_area) {  max_safe_area_insets_ = max_safe_area;
  // When the SAI is changed when DynamicSafeAreaInsetsEnabled, the SAI for the  // main frame needs to be set per browser controls state.  if (RuntimeEnabledFeatures::DynamicSafeAreaInsetsEnabled() &&      setter->IsMainFrame()) {    UpdateSafeAreaInsetWithBrowserControls(GetBrowserControls(), true);  } else {    SetSafeAreaEnvVariables(setter, max_safe_area);  }}
void Page::UpdateSafeAreaInsetWithBrowserControls(    const BrowserControls& browser_controls,    bool force_update) {  DCHECK(RuntimeEnabledFeatures::DynamicSafeAreaInsetsEnabled());
  if (Fullscreen::HasFullscreenElements() && !force_update) {    LOG(WARNING) << "Attempt to set SAI with browser controls in fullscreen.";    return;  }
  // Adjust the top / left / right is not needed, since they are set when  // display insets was received at |SetSafeArea()|.  int inset_bottom = GetMaxSafeAreaInsets().bottom();  int bottom_controls_full_height = browser_controls.BottomHeight();  float control_ratio = browser_controls.BottomShownRatio();  float dip_scale = GetVisualViewport().ScaleFromDIP();
  // As control_ratio decrease, safe_area_inset_bottom will be added to the web  // page to keep the bottom element out from the display cutout area.  float safe_area_inset_bottom =      std::max(0.f, inset_bottom - control_ratio * bottom_controls_full_height /                                       dip_scale);
  gfx::Insets new_safe_area = gfx::Insets().TLBR(      max_safe_area_insets_.top(), max_safe_area_insets_.left(),      safe_area_inset_bottom, max_safe_area_insets_.right());  if (new_safe_area != applied_safe_area_insets_ || force_update) {    applied_safe_area_insets_ = new_safe_area;    SetSafeAreaEnvVariables(DeprecatedLocalMainFrame(), new_safe_area);  }}
// staticvoid SetSafeAreaEnvVariables(LocalFrame* frame, const gfx::Insets& safe_area) {  DocumentStyleEnvironmentVariables& vars =      frame->GetDocument()->GetStyleEngine().EnsureEnvironmentVariables();  vars.SetVariable(UADefinedVariable::kSafeAreaInsetTop,                   StyleEnvironmentVariables::FormatPx(safe_area.top()));  vars.SetVariable(UADefinedVariable::kSafeAreaInsetLeft,                   StyleEnvironmentVariables::FormatPx(safe_area.left()));  vars.SetVariable(UADefinedVariable::kSafeAreaInsetBottom,                   StyleEnvironmentVariables::FormatPx(safe_area.bottom()));  vars.SetVariable(UADefinedVariable::kSafeAreaInsetRight,                   StyleEnvironmentVariables::FormatPx(safe_area.right()));}
void StyleEnvironmentVariables::SetVariable(UADefinedVariable variable,                                            const String& value) {  SetVariable(GetVariableName(variable, GetFeatureContext()), value);}
// staticconst AtomicString StyleEnvironmentVariables::GetVariableName(    UADefinedVariable variable,    const FeatureContext* feature_context) {  switch (variable) {    case UADefinedVariable::kSafeAreaInsetTop:      return AtomicString("safe-area-inset-top");    case UADefinedVariable::kSafeAreaInsetLeft:      return AtomicString("safe-area-inset-left");    case UADefinedVariable::kSafeAreaInsetBottom:      return AtomicString("safe-area-inset-bottom");    case UADefinedVariable::kSafeAreaInsetRight:      return AtomicString("safe-area-inset-right");    case UADefinedVariable::kKeyboardInsetTop:      return AtomicString("keyboard-inset-top");    case UADefinedVariable::kKeyboardInsetLeft:      return AtomicString("keyboard-inset-left");    case UADefinedVariable::kKeyboardInsetBottom:      return AtomicString("keyboard-inset-bottom");    case UADefinedVariable::kKeyboardInsetRight:      return AtomicString("keyboard-inset-right");    case UADefinedVariable::kKeyboardInsetWidth:      return AtomicString("keyboard-inset-width");    case UADefinedVariable::kKeyboardInsetHeight:      return AtomicString("keyboard-inset-height");    case UADefinedVariable::kTitlebarAreaX:      return AtomicString("titlebar-area-x");    case UADefinedVariable::kTitlebarAreaY:      return AtomicString("titlebar-area-y");    case UADefinedVariable::kTitlebarAreaWidth:      return AtomicString("titlebar-area-width");    case UADefinedVariable::kTitlebarAreaHeight:      return AtomicString("titlebar-area-height");    default:      break;  }
  NOTREACHED_IN_MIGRATION();}
void StyleEnvironmentVariables::SetVariable(const AtomicString& name,                                            const String& value) {  data_.Set(name,            CSSVariableData::Create(value, false /* is_animation_tainted */,                                    false /* needs_variable_resolution */));  InvalidateVariable(name);}

通过层层调用,我们最终看到了朝思暮想的 safe-area-inset-* 属性。至此,Android 端支持 Safe Area 的底层逻辑我们已经完全搞清楚了。

三、总结

总的来说,Android 端对前端 Safe Area 的支持其实就只是简单的把端上的 WindowInsets 中的 DisplayCutout 抛给了前端,只是其实现的方式不太优雅。

首先 View 只支持设置一个 OnApplyWindowInsetsListener 监听,这又是个公开方法,指望业务层完全不去碰这个方法是不可能的,也就导致了在实际使用时 safe-area-inset-* 属性总是会"莫名其妙"的失效。从代码注释看,Google 也注意到了这个问题并计划修改,但是目前为止还没有看到修复代码上线。 

其次,Android 对于 Safe Area 的定义和我们预期的有一些出入,在 iOS 上,Safe Area 是指不会被系统组件、挖孔/刘海等遮挡的可视区域,到了 Android 上就变成了只规避挖孔/刘海区域,一旦把设备横屏,就会发现内容又“不争气”的跑到状态栏下边去了。最后,以上内容只针对原生 WebView 实现,对于 UC 的 U4 内核等三方内核不适用,从目前的调研结果来看,UC 内核不是通过上述方式支持 Safe Area 的。

四、一些适配建议

Android 端的 Safe Area 适配目前来看是困难重重,如果仍旧“执迷不悟”的想克服这个难题,下面是我目前考虑出来的两种方案:

  • 接管系统定义 Safe Area 的能力,替换成我们想要的值。从上面的分析过程我们也可以看出来,Android 端的 Safe Area 参数之所以不符合预期,主要是值的来源就不对,如果我们能够把 WindowInsets 里的值改成期望的值,那么后面的所有问题都迎刃而解了。下面是一个简单实现:

override fun onCreate(savedInstanceState: Bundle?) {  webView = findViewById(R.id.webview)  //这里先通过一定途径获取到由AwDisplayCutoutController设置的Listener,否则会被我们设置的Listener覆盖掉  val onApplyWindowInsetsListener = webView?.getOnApplyWindowInsetsListeners()?.get(0)  webView?.setOnApplyWindowInsetsListener { view, insets ->      val builder = DisplayCutout.Builder()      val originCutout = insets.displayCutout      val cutoutPath = originCutout?.cutoutPath      if (cutoutPath != null) {          builder.setCutoutPath(cutoutPath)      }      val systemBarsInsets: Insets = insets.getInsets(WindowInsets.Type.systemBars())      val imeInsets = insets.getInsets(WindowInsets.Type.ime())      val bottom = if (imeInsets.bottom > 0) {          0      } else {          systemBarsInsets.bottom + OFFSET      }      builder.setSafeInsets(Insets.of(          max(systemBarsInsets.left, originCutout?.safeInsetLeft ?: 0),          max(systemBarsInsets.top + OFFSET, originCutout?.safeInsetTop ?: 0),          max(systemBarsInsets.right, originCutout?.safeInsetRight ?: 0),          max(bottom, originCutout?.safeInsetBottom ?: 0)      ))      builder.setWaterfallInsets(originCutout?.waterfallInsets ?: Insets.NONE)      builder.setBoundingRectTop(originCutout?.boundingRectTop ?: Rect())      builder.setBoundingRectLeft(originCutout?.boundingRectLeft ?: Rect())      builder.setBoundingRectBottom(originCutout?.boundingRectBottom ?: Rect())      builder.setBoundingRectRight(originCutout?.boundingRectRight ?: Rect())      val newInsets = WindowInsets.Builder(insets).setDisplayCutout(builder.build()).build()      onApplyWindowInsetsListener?.onApplyWindowInsets(view, newInsets) ?: view.onApplyWindowInsets(newInsets)  }}

但是由于这个方式需要依赖系统能力,要考虑设备系统版本以及后续 API 发生变更的影响。

  • 和前端约定好,定义一套新的参数。Android 端一直面临碎片化的兼容适配,对于自研前端为主的场景,为了提升兼容性,可以自己定义一套参数来做适配。下面是一个简单实现:

override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)        val rootView = findViewById<View>(android.R.id.content)        window.decorView.getRootView().setOnApplyWindowInsetsListener { view, insets ->            updateInsets()            view.onApplyWindowInsets(insets)        }
        // 触发获取 WindowInsets        rootView.requestApplyInsets()}
private fun updateInsets() {    runOnUiThread {        WindowCompat.setDecorFitsSystemWindows(window, false)
        val windowInsets = ViewCompat.getRootWindowInsets(window.decorView)        val systemBarsInsets = windowInsets?.getInsets(WindowInsetsCompat.Type.systemBars()) ?: Insets.NONE        val imeInsets = windowInsets?.getInsets(WindowInsetsCompat.Type.ime()) ?: Insets.NONE
        val density = resources.displayMetrics.density
        setProperty("top", Math.round(systemBarsInsets.top / density) + offset)        setProperty("left", Math.round(systemBarsInsets.left / density))        if (imeInsets.bottom > 0) {            setProperty("bottom", 0)        } else {            setProperty("bottom", Math.round(systemBarsInsets.bottom / density) + offset)        }        setProperty("right", Math.round(systemBarsInsets.right / density))
        // To get the actual height of the keyboard, we need to subtract the height of the system bars from the height of the ime        // Source: https://stackoverflow.com/a/75328335/8634342        val imeHeight = imeInsets.bottom - systemBarsInsets.bottom
        // Set padding of decorview so the scroll view stays correct.        // Otherwise the content behind the keyboard cannot be viewed by the user.        window.decorView.setPadding(0, 0, 0, imeHeight)    }}
private fun resetProperties() {    setProperty("top", 0)    setProperty("left", 0)    setProperty("bottom", 0)    setProperty("right", 0)}
private fun setProperty(position: String, size: Int) {    runOnUiThread {        webView?.loadUrl("javascript:document.querySelector(':root')?.style.setProperty('--custom-safe-area-inset-" + position + "', 'max(env(safe-area-inset-" + position + "), " + size + "px)');void(0);")    }}
<!DOCTYPE html><html lang="zh"><head>    <meta charset="UTF-8">    <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">    <title>显示 Padding 值</title>    <style>        :root {            --custom-safe-area-inset-top: 0px;            --custom-safe-area-inset-bottom: 0px;            --custom-safe-area-inset-left: 0px;            --custom-safe-area-inset-right: 0px;        }        body {            margin: 0;            padding-top: var(--custom-safe-area-inset-top);            padding-bottom: var(--custom-safe-area-inset-bottom);            padding-left: var(--custom-safe-area-inset-left);            padding-right: var(--custom-safe-area-inset-right);            background-color: #f0f0f0;            font-family: Arial, sans-serif;        }        .padding-info {            background-color: #fff;            border: 1px solid #ddd;            height: 100vh;            padding: 10px;            box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);        }</style></head><body><div class="padding-info" id="paddingDisplay">    <input type="text" id="inputField" name="inputField" placeholder="在这里输入..."></div></body></html>

自定义参数不用考虑 API 变更的影响,但是只能针对自研适配的前端页面生效。两种方式各有优劣,如果有更好的方式也欢迎探讨。

相关文章:

如何让 Android 的前端页面像 iOS 一样“优雅”?

作者:方英杰&#xff08;崇之&#xff09; 最近在调研前端页面适配 Android 端异形屏的方案&#xff0c;调研过程中发现了一些比较有意思的点&#xff0c;本文主要是做一个总结。 一、提出问题 首先&#xff0c;我们需要知道 Android 上的前端适配面临着什么问题。 问题其实很…...

10.3学习

1.循环依赖 循环依赖其实就是循环引用&#xff0c;也就是两个或者两个以上的 Bean 互相持有对方&#xff0c;最终形成闭环。比如A 依赖于B&#xff0c;B又依赖于A Spring中循环依赖场景有: prototype 原型 bean循环依赖 构造器的循环依赖&#xff08;构造器注入&#xff09;…...

Shell文本处理(三)

Shell文本处理三:字符串处理 1、字符串截取(切片)2、字符串替换3、字符串删除4、去除空格5、大小写转换6、字符串分割7、去除中文在Shell中,字符串没有单独的数据类型,一切都是变量。但这并不意味着我们不能像在Java、Python等其他编程语言中那样处理字符串 1、字符串截取…...

5个python多线程简单示例

示例 1: 基本线程创建 # 示例 1: 基本线程创建 import threading import timedef print_numbers():for i in range(5):time.sleep(1)print(i)# 创建线程 thread threading.Thread(targetprint_numbers)# 启动线程 thread.start()# 等待线程完成&#xff08;可选&#xff09; …...

Streamlit:用Python快速构建交互式Web应用

在传统的Web开发中&#xff0c;开发者常常需要编写大量的前端和后端代码&#xff0c;才能实现一个简单的交互式Web应用。Streamlit 通过简化这一过程&#xff0c;使得你只需要用Python编写代码&#xff0c;就能快速创建具有丰富交互功能的Web应用。本文将介绍如何使用Streamlit…...

深入浅出Vue.js组件开发:从基础到高级技巧

解锁Python编程的无限可能:《奇妙的Python》带你漫游代码世界 Vue.js 是一个轻量级且功能强大的 JavaScript 框架,专注于构建用户界面。它的核心优势之一是组件系统,它允许开发者通过模块化、可复用的方式构建复杂的应用程序。在这篇文章中,我们将详细探讨如何开发 Vue.js…...

Python并发编程挑战与解决方案

Python并发编程挑战与解决方案 并发编程是现代软件开发中的一项核心能力&#xff0c;它允许多个任务同时运行&#xff0c;提高程序的性能和响应速度。Python因其易用性和灵活性而广受欢迎&#xff0c;但其全局解释器锁&#xff08;GIL&#xff09;以及其他特性给并发编程带来了…...

LeetCode从入门到超凡(五)深入浅出---位运算

引言 大家好&#xff0c;我是GISer Liu&#x1f601;&#xff0c;一名热爱AI技术的GIS开发者。本系列文章是我跟随DataWhale 2024年9月学习赛的LeetCode学习总结文档&#xff1b;本文主要讲解 位运算算法。&#x1f495;&#x1f495;&#x1f60a; 一、 位运算简介 1.什么是位…...

一些 Go Web 开发笔记

原文&#xff1a;Julia Evans - 2024.09.27 在过去的几周里&#xff0c;我花了很多时间在用 Go 开发一个网站&#xff0c;虽然不知道它最终会不会发布&#xff0c;但在这个过程中我学到了一些东西&#xff0c;想记录下来。以下是我的一些收获&#xff1a; Go 1.22 现在有了更…...

[Go语言快速上手]初识Go语言

目录 一、什么是Go语言 二、第一段Go程序 1、Go语言结构 注意 2、Go基础语法 关键字 运算符优先级 三、Go语言数据类型 示例 小结 一、什么是Go语言 Go语言&#xff0c;通常被称为Golang&#xff0c;是一种静态类型、编译型的计算机编程语言。它由Google的Robert Gr…...

基于STM32的智能风扇控制系统设计

引言 本项目将基于STM32微控制器设计一个智能风扇控制系统&#xff0c;通过温度传感器实时检测环境温度&#xff0c;并根据预设的温度范围自动调节风扇的转速。该系统展示了STM32的PWM输出、传感器接口以及自动控制应用的实现。 环境准备 1. 硬件设备 STM32F103C8T6 开发板…...

OpenCV 形态学相关函数详解及用法示例

OpenCV形态学相关的运算包含腐蚀(MORPH_ERODE)&#xff0c;膨胀(MORPH_DILATE)&#xff0c;开运算(MORPH_OPEN)&#xff0c;闭运算(MORPH_CLOSE)&#xff0c;梯度运算(MORPH_GRADIENT)&#xff0c;顶帽运算(MORPH_TOPHAT)&#xff0c;黑帽运算(MORPH_BLACKHAT)&#xff0c;击中…...

Kafka学习笔记(三)Kafka分区和副本机制、自定义分区、消费者指定分区

文章目录 前言7 分区和副本机制7.1 生产者分区写入策略7.1.1 轮询分区策略7.1.2 随机分区策略7.1.3 按key分区分配策略7.1.4 自定义分区策略7.1.4.1 实现Partitioner接口7.1.4.2 实现分区逻辑7.1.4.3 配置使用自定义分区器7.1.4.4 分区测试 7.2 消费者分区分配策略7.2.1 RangeA…...

华为 HCIP-Datacom H12-821 题库 (31)

&#x1f423;博客最下方微信公众号回复题库,领取题库和教学资源 &#x1f424;诚挚欢迎IT交流有兴趣的公众号回复交流群 &#x1f998;公众号会持续更新网络小知识&#x1f63c; 1. 默认情况下&#xff0c;IS-IS Level-1-2 路由器会将 Level-2 区域的明细路由信息发布到Lev…...

占位,凑满减

占位&#xff0c;凑满减...

SpringBoot校园资料平台:从零到一的构建过程

1系统概述 1.1 研究背景 如今互联网高速发展&#xff0c;网络遍布全球&#xff0c;通过互联网发布的消息能快而方便的传播到世界每个角落&#xff0c;并且互联网上能传播的信息也很广&#xff0c;比如文字、图片、声音、视频等。从而&#xff0c;这种种好处使得互联网成了信息传…...

czx前端

一、盒模型 标准盒模型&#xff1a;box-sizing: content-box。 外边距边框内边距内容区。 IE盒模型&#xff0c;怪异盒模型&#xff1a;box-sizing: border-box。 外边距内容区&#xff08;边框内边距内容区&#xff09;。 二、CSS特性 继承性: 父元素的字体大小&#xf…...

Perforce演讲回顾(上):从UE项目Project Titan,看Helix Core在大型游戏开发中的版本控制与集成使用策略

日前&#xff0c;Perforce携手合作伙伴龙智一同亮相Unreal Fest 2024上海站&#xff0c;分享Helix Core版本控制系统及其协作套件的强大功能与最新动态&#xff0c;助力游戏创意产业加速前行。 Perforce解决方案工程师Kory Luo在活动主会场&#xff0c;带来《Perforce Helix C…...

【含文档】基于Springboot+Andriod的成人教育APP(含源码+数据库+lw)

1.开发环境 开发系统:Windows10/11 架构模式:MVC/前后端分离 JDK版本: Java JDK1.8 开发工具:IDEA 数据库版本: mysql5.7或8.0 数据库可视化工具: navicat 服务器: SpringBoot自带 apache tomcat 主要技术: Java,Springboot,mybatis,mysql,vue 2.视频演示地址 3.功能 系统定…...

CentOS7系统配置Yum环境

新安装完系统的服务器往往缺少我们常用的依赖包&#xff0c;故需要设置好yum源&#xff0c;方便软件安装&#xff0c;以下是CentOS7为例&#xff0c;系统安装后yum默认安装。 //备份之前的配置文件 mv /etc/yum.repos.d /etc/yum.repos.d.bak mkdir -p /etc/yum.repos.d 1…...

<6>-MySQL表的增删查改

目录 一&#xff0c;create&#xff08;创建表&#xff09; 二&#xff0c;retrieve&#xff08;查询表&#xff09; 1&#xff0c;select列 2&#xff0c;where条件 三&#xff0c;update&#xff08;更新表&#xff09; 四&#xff0c;delete&#xff08;删除表&#xf…...

Leetcode 3576. Transform Array to All Equal Elements

Leetcode 3576. Transform Array to All Equal Elements 1. 解题思路2. 代码实现 题目链接&#xff1a;3576. Transform Array to All Equal Elements 1. 解题思路 这一题思路上就是分别考察一下是否能将其转化为全1或者全-1数组即可。 至于每一种情况是否可以达到&#xf…...

微软PowerBI考试 PL300-选择 Power BI 模型框架【附练习数据】

微软PowerBI考试 PL300-选择 Power BI 模型框架 20 多年来&#xff0c;Microsoft 持续对企业商业智能 (BI) 进行大量投资。 Azure Analysis Services (AAS) 和 SQL Server Analysis Services (SSAS) 基于无数企业使用的成熟的 BI 数据建模技术。 同样的技术也是 Power BI 数据…...

SCAU期末笔记 - 数据分析与数据挖掘题库解析

这门怎么题库答案不全啊日 来简单学一下子来 一、选择题&#xff08;可多选&#xff09; 将原始数据进行集成、变换、维度规约、数值规约是在以下哪个步骤的任务?(C) A. 频繁模式挖掘 B.分类和预测 C.数据预处理 D.数据流挖掘 A. 频繁模式挖掘&#xff1a;专注于发现数据中…...

2.Vue编写一个app

1.src中重要的组成 1.1main.ts // 引入createApp用于创建应用 import { createApp } from "vue"; // 引用App根组件 import App from ./App.vue;createApp(App).mount(#app)1.2 App.vue 其中要写三种标签 <template> <!--html--> </template>…...

转转集团旗下首家二手多品类循环仓店“超级转转”开业

6月9日&#xff0c;国内领先的循环经济企业转转集团旗下首家二手多品类循环仓店“超级转转”正式开业。 转转集团创始人兼CEO黄炜、转转循环时尚发起人朱珠、转转集团COO兼红布林CEO胡伟琨、王府井集团副总裁祝捷等出席了开业剪彩仪式。 据「TMT星球」了解&#xff0c;“超级…...

2021-03-15 iview一些问题

1.iview 在使用tree组件时&#xff0c;发现没有set类的方法&#xff0c;只有get&#xff0c;那么要改变tree值&#xff0c;只能遍历treeData&#xff0c;递归修改treeData的checked&#xff0c;发现无法更改&#xff0c;原因在于check模式下&#xff0c;子元素的勾选状态跟父节…...

Spring Boot+Neo4j知识图谱实战:3步搭建智能关系网络!

一、引言 在数据驱动的背景下&#xff0c;知识图谱凭借其高效的信息组织能力&#xff0c;正逐步成为各行业应用的关键技术。本文聚焦 Spring Boot与Neo4j图数据库的技术结合&#xff0c;探讨知识图谱开发的实现细节&#xff0c;帮助读者掌握该技术栈在实际项目中的落地方法。 …...

自然语言处理——Transformer

自然语言处理——Transformer 自注意力机制多头注意力机制Transformer 虽然循环神经网络可以对具有序列特性的数据非常有效&#xff0c;它能挖掘数据中的时序信息以及语义信息&#xff0c;但是它有一个很大的缺陷——很难并行化。 我们可以考虑用CNN来替代RNN&#xff0c;但是…...

[Java恶补day16] 238.除自身以外数组的乘积

给你一个整数数组 nums&#xff0c;返回 数组 answer &#xff0c;其中 answer[i] 等于 nums 中除 nums[i] 之外其余各元素的乘积 。 题目数据 保证 数组 nums之中任意元素的全部前缀元素和后缀的乘积都在 32 位 整数范围内。 请 不要使用除法&#xff0c;且在 O(n) 时间复杂度…...