[targetSDK升级为35] 恶心的EdgeToEdge适配

开发多年, 碰到不少恶心需求与恶心甲方了. 但这次edgeToEdge真是刷新了我对google下限的想像.

事情是Google要求8月底前, 所有play store上的app升级targetSDK为35 (政策链接是这个: developer.android.com/google/play..., 目前只说2024年8月底前到34; 但按google每年的规律, 今年8月底前要是升到35的). 其它所有变动都可以理解, 只有一项是恶心的EdgeToEdge, 即Google强制所有app的所有页面全部变为全屏模式(full screen, edge to edge).

一. EdgeToEdge为何让人火大?

先来个图说明下这个强制要求会让我们的app如何变化:

恶心的点就在于Google是强制所有app都要这样做, 而且还有一些细节点很恶心, 后续会讲到. 这样就给我们的每一个app都造成了麻烦. 我们app设计得好好的, 为什么一定要全屏.


而且你强制让最上面的OS status bar给占了有什么意思哦, 这不是把"时间, 电量, wifi信号, .."这些都给遮挡了嘛?!

所以Google就会让你做适配, 不要占了status bar (如上图的And15+所示, 页面内部就没有占据status bar的区域)? 我就晕死了, 那适配后的效果跟不全屏不就一样了, 那你强制全屏有什么意思!!!!


同理, 你的手机要是底部是"三按钮"或"二按钮"的OS navigation区域(如"后退键 + home键 + recentTasks键"), 那全屏后你的内容也会和这个区域重合.

重合自然是不行的, 不然你按下去, 到底是响应OS的back键, 还是响应你页面中的某View的点击呢? 所以你肯定要做成不重合.

那又回到原点? 要是不强制全屏, 本来就不重叠; 你现在强制全屏, 又要给开发增加额外工作量来做到不重合. 这就像极了, 本来一条路好好的, 你非要拆了, 再重新修一遍, 重新搞得和原来的路一样, 那你干嘛重修这条路?

p.s. Google离最初的"不作恶"初心越来越远了, 现在的Android更新也不侧重于用户的体验, 反而一个劲地推销自己的AI. 很多新东西也越来越倾向于大厂的开发/经理为了自己的KAP生硬地搞新东西, 而不是想这些新东西能解决什么难受的痛点.

二. 着手做之前的分析

虽然这个需求让人很无语, 吐槽完后做还是肯定要做的. 不过我们有必要在编码之前, 先分解下这个需求, 以及分析下它到底影响了哪些部分, 这样我们才能一步一步地去解决这些问题.

2.1 影响哪些设备?

经过测试, 一个app的targetSDK为34-时, 在任何设备上都不会被强制全屏.

但要是你的app升级targetSDK到了35+了, 那么:

  • 在Android 14-的设备上, 不会全屏
  • 在Android 15+的设备上, 会强制全屏.
    • (这个其实有个后门, 在第三章 最快的适配里会讲到)

这个意味着什么?

: 意味着, 要是我做了某些操作, 来让Android 15+适配此需求; 但同时我需要要让Android 14-不要受此影响 (因为Android 14-的设备没有强制全屏嘛!)

2.2 影响所有页面, 所有组件吗?

若你升级了targetSDK到35+, 那在Android 15+的设备上就会强制全屏.

不过, 有些细节要注意:

  • 若你的页面本来就是全屏 (如很多app的splashActivity本身就是全屏, 或是video play页面本身也是全屏), 那它不受影响, 仍会是全屏的.
  • 若你的页面本身没有全屏, 那就会被强制全屏.

所以问题的规模再次缩小: 我们需要让所有本来非全屏的页面来适配这个edgeToEdge的要求.

另外强制一下, 而这里要做的改动是涉及到: Activity, Fragment, BottomSheetDialogFragment, ... 这些所有组件的哦!

2.3 代码上如何实现?

其实现在你新建一个Android工程, 它的Activity会写上:

kotlin 复制代码
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContentView(R.layout.activity_main)
        ViewCompat.setOnApplyWindowInsetsListener(findViewById(R.id.main)) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars())
            v.setPadding(systemBars.left, systemBars.top, systemBars.right, systemBars.bottom)
            insets
        }
    }
}

它这里的R.id.main就是整个Activity的rootView:

xml 复制代码
<ConstarintLayout android:id="@+id/main" ...>
    <TextView ..../>
</ConstraintLayout>   

这个代码已经就已经是我们的解决方案了. 其核心思想就是:

  • 在时机合适时 (OnApplyWindowInsetsListener)时, 给Activity中的内容加一个上下左右的padding, 这样就可以让我们app的页面不和上面的status bar重叠, 也不全下面的navigation zone重叠.

2.5 思路有了

那只要让Activity的rootView做一定的padding就行了, 相当于是:

  • paddingTop = statusBar.height
  • paddingBottom = navigationZone.height

那我们完全可以在BaseActivity中用:

kotlin 复制代码
class BaseActivity : AppCompatActivity() {
    override fun onCreate(bundle) {
        super.onCreate(bundle)
        enableEdgeToEdge()
        setContentView(...)
        ViewCompat.setOnApplyWindowInsetsListener { 
            rootView.setPadding(...)
        }
    }
}

这个问题貌似这样简单一个设置就能成功让所有页面适配edgeToEdge了. 哎, 事情要是能这么简单, 那也算google良心了. 可偏偏google搞了好多事情, 让我们本来困难的适配工作变得更加艰难.

2.5 困难1: top方向

第一个困难就来自于Google设计人员的有限小脑以为所有页面都是非全屏的, 所以你上面的设置就没问题.

但上面第2.2小节说了, 原本已经全屏的页面是不受影响的. 你要是再加上个paddingTop, 那效果反而变成非全屏了.

所以具体到top的方向, 我们就要区分处理:

  • Android 14- (不全屏)
  • Andorid 15+的全屏页面 (不要做paddingTop)
  • Android 15+的非全屏页面(要做paddingTop)

2.6 困难2: top方向

其实从上一个图中可以看到, 我们的第二个问题, 那就是我们把Activity的rootView加了一个paddingTop后, 就把statusBar的区域给空出来了, 但空出来的好怪, 成了一个透明色, 搞得时间, 电量, 这些信息都看不见了.

所以在top方向上, 我们要做的其实是:

  • Android 14- (不全屏)
  • Andorid 15+的全屏页面 (不要做paddingTop)
  • Android 15+的非全屏页面
    • (要做paddingTop)
    • (还要给statusBar区域加上颜色)

2.7 困难3: bottom方向

联明的你肯定想到了bottom的方向上的困难也是类似的, 也要给navigation zone添加颜色 (加了paddingBottom后其背景色也成了空白了). 是的, 这样的想法是对的.

但其实更困难的点来自于Google的Material库.

arduino 复制代码
implementation 'com.google.android.material:material:1.12.0'

比如说, 若你使用了Material库中的BottomNavigationView, 来看下效果:

是的, Google"贴心"地已经为Material库里做好了edgeToEdge的适配. 但这种所谓的"贴心"反而增加了我们的工作量, 因为我们本来就是想在BaseActivity中简单加上 paddingBottom = systembars.bottom, 现在发现不能这样了, 因为有了BottomNavigationView这样的, 底部paddingBottom应该为0了.

小总结下, 在bottom方向

  • Android 14- 不用变化
  • Android 15+ && 有BottomNavigationView/BottomAppBar: 这时paddingBottom应该为0
  • Android 15+ && 没有BottomNavigationView/BottomAppBar: paddingBottom应该为systemBar.bottom

2.8 困难4: BottomSheet

当我们变化targetSDK为35后, bottomSheet也变了, 来看下示例:

我们的BottomSheet布局如下:

xml 复制代码
<LinearLayout orientation = "vertical">
    <RecyclerView height="0dp" weight="1"/>
    <Buttton height="40dp" />
</LinearLayout>           

即是把Button先放到最下方, 然后RecyclerView再占据剩下的所有空间.

从上面的表格可以知道, And15+下,

  • Navigation Zone倒是和BottomSheet没有重叠
  • 但是BottomSheet的下方一大段内容 (测试约有110dp的样子)消失了.

所以我们要对Android 15+的BottomSheet也要特别处理一下.

2.9 小总结

综上所述,

  • Google的Material库会自动适配, 导致我们要特别处理一下它;
  • 同时要是已经全屏的页面, 也要处理下;
  • 同时要注意下And 14-, And 15+有所不同, 要小心点

2.10 more...

好吧, 其实还有更多细节. 不过更多的细节问题只是小问题了, 我们后续会讲到, 并不影响我们这里的分类了.

三. 最快的适配 (有后患)

上面的分析已经大致了解了要分别处理哪几点, 但其实若你的时间紧, 来不及处理这么多, Google是开了个后门的. 你可以这样:

step 1.

找到application的theme

step 2.

给这个theme添加一个属性:

xml 复制代码
<item 
  name="android:windowOptOutEdgeToEdgeEnforcement" 
  tools:targetApi="35">true
</item>

这样一来, 你的页面即使在Android 15+设备上也不会强制全屏. 不过Google也说明了, 这个flag的设置只在Android 15有用, 到了Android 16上你就是设置了它为true, 也会被Android给忽略掉.

所以设置这个flag, 只能让你今年不去适配edgeToEdge, 但明年(2026年)8月底, 你还是要去适配edgeToEdge的. 即还是要走第四章 稳当的适配的各个步骤.

四. 稳当的适配 (无患)

4.1 升级Activityx库 (可选)

现在既然升级到了targetSDK = 35, 那一些Androidx库也可以升级了, 如:

gradle 复制代码
implementation "androidx.activity:activity-ktx:1.9.0"
implementation "androidx.fragment:fragment-ktx:1.7.1"

这些库一般是和targetSDK绑定的, 你不升到某个版本还用不了高版本的activity-ktx库.

升级它的原因是我们需要Activity中的enableEdgeToEdge()方法, 低版本的activity-ktx里没有.

4.1.1 为何说它是可选

因为在我的测试过程中, 我发现enableEdgeToEdge()其实并不是必需的.

是的, 现在Google官网, Google的demo, 以及很多文章都在介绍:

  • 你要要使用enableEdgeToEdge()这个方法,
  • 再配合ViewCompat.setOnApplyWindowInsetsListener(rootView) {v, insets -> ...}来调整padding 这就行了.

但我想了想, 其实它并不是必需的. 在我的多次测试下, 如何你没用调用enableEdgeToEdge()方法, 那么:

  • Android 14-上页面也不全屏.
    • ViewCompat.setOnApplyWindowInsetsListener()这个回调不会被触发
  • Android 15+上页面仍是全屏.
    • ViewCompat.setOnApplyWindowInsetsListener()这个回调仍会被触发

但要是你调用了enableEdgeToEdge(), 那么:

  • Android 14-上页面就是全屏
  • Android 15+上页面也是全屏.
  • 在所有机型上, ViewCompat.setOnApplyWindowInsetsListener()这个回调都被触发了

说到这里, 其实我们就明白了,

  • Android 15+自动强制全屏, 即相当于为我们调了enableEdgeToEdge()
  • Android 14-上不调用enableEdgeToEdge()就不会是全屏.

=> 所以enableEdgeToEdge()是想在全平台上实现全屏效果.

我都哭死了, 这么恶心的全屏效果我才不想在全屏上实现呢. 我只想让Android 15+有这样的效果就行了. 所以我的适配方案是没有调用的enableEdgeToEdge()方法的!!

4.2 top方向上的调整

不调用enableEdgeToEdge(), 那如何调整top上的全屏呢?

我们来看几种情况:

  • Android 14-: 因为没调用enableEdgeToEdge(), 所以完全不用理会这种情况
  • Android 15+ && 全屏页面: 就是升级到targetSDK = 35, 效果仍不变, 所以不用管
  • Android 15+ && 非全屏页面: 这些页面我们都是有ActionBar/ToolBar/TopBar在上面的, 如下图所示:

这里有一个规律: 即非全屏页基本上全是有ActionBar/TopBar/ToolBar这样的头在top上的, 所以我们只要这样做就能完美适配了: 假设我们的topBar原本调试是50dp, statusBar调试是34dp, 所以我们原来的dimen是: topbar_height = 50dp的.

现在我们修改下, 在topBar上新加一个View, 宽度为全屏, 高度为top_placeholder_height:

  • res/values/dimens.xml : 设置 top_placeholder_height = 0dp
  • res/values-v35/dimens.xml: 设置toolbar_height = 34dp

当然你若是担心statusBar高度不一定是34dp, 也可以在代码中得到后更新这个topbar的高度即可:

kotlin 复制代码
// BaseActivity
val isAndroid15 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.VANILLA_ICE_CREAM //VANILLA_ICE_CREAM是35
val hasTopBar = contentView.findViewById(R.id.topbar) != null
if (isAndroid15 && hasTopBar)  { 
    topPlaceHolderView.updateLayoutParams<ViewGroup.LayoutParams> { 
        height = getStatusBarHeight() 
    }
}

小结:

  • Android 14-与Android 15+的全屏页面可以不用管
  • Android 15+的非全屏页面, 只要调整下actionBar的调试即可.

没有使用enableEdgeToEdge(), 也没有使用paddingTop = systembars.top, 只在有TopBar的地方加一个空view即可. 空View的高度则是根据Android版本不同而变化.

4.3 Bottom方向

4.3.1 处理paddingBottom

这个因为有些页面有BottomNavigationBar, 有些没有, 所以只好全局地设置了, 我们应该要在BaseActivity中设置:

kotlin 复制代码
    override fun onStart() {
        val pageRootView = window.decorView.findViewById<ViewGroup>(android.R.id.content).getChildAt(0)
        val hasBottomBar = pageRootView.findViewById<BottomNavigationView>(R.id.bottomBar) != null
        ViewCompat.setOnApplyWindowInsetsListener(pageRootView) { v, insets ->
            val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) 
            val paddingBottom = if(hasBottomBar) 0 else systemBars.bottom
            v.updatePadding(systemBars.left, 0, systemBars.right, paddingBottom)
            insets
        }
    }
  • 说明1: BaseActivity还不知道每个子类页面是inflate了哪个layout XML, 所以我们要在onStart这种已经inflate完毕的时机里去调用这个ViewCompat.setOnApplyWindowInsetsListener方法

  • 说明2: 如上面所述, 有bottomNavigationView的地方, 我们就不要调整paddingBottom了, 所以有了上面的val paddingBottom = if(hasBottomBar) 0 else systemBars.bottom的设置

  • 说明3: Android 14-的设备, 因为没有调用enableEdgeToEdge()方法, 所以这个ViewCompat.setOnApplyWindowInsetsListener回调根本不会被调用, 所以无须担心Android 14-的设备了. 它们的体验和以前一样的.

效果如下:

明显从上面看到这个navigation zone的颜色不太对, 太透明了, 所以我们来修改下:

kotlin 复制代码
override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    window.navigationBarColor = getColor(R.color.purple)
}

效果如下:

备注: 要是有同学看其源码, 能发现这方法说And 15+上, 这个方法将无效 -> 不过, 我的实践证明这个方法在targetSDK = 35时仍可以用.

4.4 BottomSheet的处理

这里又要骂Google不当人子了, 你既然是所有页面都要app们去适配, 那为何不让开发们自己去做就好了? Google一定要去修改BottomNaivgationView, BottomSheet的代码, 导致我们app的开发工作量更大了.

这里也是, BottomSheet的处理更复杂, 原因就是BottomSheet来自于Google Material库.

4.4.1 他人的建议

文章来源于: 《Advanced Android Edge-ToEdge: BottomSheet》

这位作者的做法就是分两步

第一步, 修改theme

第二步, Google Material把BottomSheet的多个View都设置成了 fitsSystemWindows = true了, 我们要设置为false (And 14-就是false), 并做一定修改:

我试了下这些做法, 可以说, 效果有, 但并不完美, 我们app的页面看起来和And14-的效果不一样. 所以我最终没有采用方法.

(不过我仍然列出这篇文章在这里, 说不定其它同学会觉得它正好适合于你们app)

4.4.2 自己加paddingBottom

好在我们App中的BottomSheet并不多, 只有3个. 所以我的做法就是像给topBar加一个View做为paddingBottom. 这样反而效果和我们预期的一样.

不过仍是有坑, 那就是我得出来的systemBars.bottom约为46dp, 于是我给BottomSheet加了一个高度为46dp的空view

xml 复制代码
// sheet_carousel_info.xml
<LinearLayout orientation="vertical">
    <RecyclerView height="0dp" weight="1">
    <Button height="50dp"/>
    <View height="@dimen/bottom_placeholder_height"/>
</LinearLayout>

这个bottom_placeholder_heightvalues-v35里设置为46dp, 却仍是让button给显示不出来.

我最终设置成110dp, button才成功显示出来, 我也是晕了.

4.5 ConstraintLayout的坑

上面讲了, 在处理top, 以及BottomSheet时, 我们都采用了不同OS上有不同dimen的做法, 如:

  • values/dimens.xml : 0dp
  • values-v35/dimens.xml : 34dp

但这个做法在碰到ConstraintLayout时就出问题了. 以top为例哦, 这样的设置后, 来看下效果:

即在And 15+上正常, 在And14-上却是top上的palceHolderView占据了整个屏幕.

原因就是在ConstraintLayout中, 0dp不再代表0高度, 而是代表根据约束来. 所以这就可能导致上面点满全屏的情况.

(再骂一句Google, 0dp就应该只有一种意义, 表示0高度; 而不是有2种意思, 导致了现在的问题)

解决办法其实也简单, 不过是我老婆想到的. 她说既然0dp不行, 那我设置为0.01dp总可以吧. 果然, 试了下, 真的行!

所以为了兼容ConstraintLayout, 当使用dimen方案时, 请这样设置:

  • values/dimens.xml : 0.01dp
  • values-v35/dimens.xml : 34dp

这样就基本Okay了.

4.6 总结

  • 针对top方向, 只要处理非全屏页面, 所以我们给topBar加了一个高度可变的topPlaceHolderView
    • (全局地生效)
  • 针对bottom方向, 要用Google推荐的设置paddingBottom的方法,
    • (全局地生效)
    • 只不过要注意 bottomNavigationView, 以及OS navigation zone的颜色
  • 针对BottomSheet,
    • 要么用我推荐的文章 (全局地生效)
    • 要么用我的方法(手动地一个个地加bottomPlaceHolderView)

五. 其它的可能有影响的点

5.1 Material2的theme

我没有碰到这种情况(因为我用的是material1的theme), 但老婆碰到了. 老婆说她们公司用的是Material2的theme, 这时要适配edgeToEdge, 就得升级到Material3的theme.

但熟悉的朋友一定发现了, 改整个Application的theme是个非常危险的操作, 意味着可能每个页面都有所变化, 可能你要修改每个页面的UI了.

具体方法可能只能升级到material3页面, 然后一个页面一个页面地修改material theme升级带来的影响了.

5.2 Dark theme

若你的app还支持dark theme, 那你会发现BottomSheet的方案有问题, 在Android 15设备上paddingBottom过大了:

原因也是Google Material库的锅.

  • Android认为, 又是values-v35, 又是dark theme的情况下, 那dimen的值就去values-v35中取, 自然paddingBottom这时就是100dp
  • 但是, Google Material库认为, 一个dark theme的BottomSheet, style应该走dark, 所以它的各个子view又不用得像是Android 14一样(即fitsSystemWindows = false)

这二者一结合就冲突了. 但明显是Android的处理更正常.

正确的解决办法, 新加一个res/values-night-v35目录:

总结下来就是:

  • values/dimens.xml:
    • bottomSheet_paddingBottom = 0.001dp
  • values-v35/dimens.xml:
    • bottomSheet_paddingBottom = 110dp
  • values-night-v34/dimens.xml:
    • bottomSheet_paddingBottom = 0.001dp

六. 结语

从上面的篇幅, 可以看出我在这上面花费了多少时间. Google这种强制app更新UI的方式很恶心, 偏偏它的Material库又给我们增加了很多障碍. 好在现在大体完工了. 多谢你的阅读~

参考资料

1). developer.android.com/about/versi...

2). developer.android.com/develop/ui/...

3). BottomSheet与EdgeToEdge

4). 老婆的各种实践

5). 我自己的各种实践

相关推荐
移动开发者1号几秒前
Kotlin中的let、run、with、also、apply、invoke用法详解
android
肖叶海4 分钟前
Android实现倒计时的几种方案
android
louisgeek1 小时前
Android NSD 网络服务发现
android
张可2 小时前
历时两年半开发,Fread 项目现在决定开源,基于 Kotlin Multiplatform 和 Compose Multiplatform 实现
android·前端·kotlin
余辉zmh2 小时前
【Linux系统篇】:信号的生命周期---从触发到保存与捕捉的底层逻辑
android·java·linux
孤鸿玉3 小时前
[Flutter小试牛刀] 低配版signals,添加多层监听链
android·前端·响应式设计
雨和卡布奇诺3 小时前
LiveData源码浅析
android
淡蓝色_justin3 小时前
Hilt-plus 简介
android·android jetpack
app1e2343 小时前
ctfshow web入门 命令执行(29-77)
android·前端