1. 先搞清楚那条底部阴影到底是谁画的第一次遇到这个问题是在做一个卡片式的列表页。设计稿上的按钮是纯色圆角块干净利落结果跑起来底部总有一层灰蒙蒙的投影像贴了一张纸片在屏幕上。我当时的直觉是背景图里带了渐变二话不说把background换成纯色 shape阴影还在又把android:elevation0dp加上去阴影依然稳如泰山。折腾了小半个下午才反应过来这条阴影既不是背景画的也不是elevation属性管得住的它来自AppCompatButton默认携带的stateListAnimator——一个在 Material 设计体系里用来表达“这个控件是可以按下去的”的动画状态机。这件事之所以值得单独记一笔是因为它几乎踩中了所有“看起来很简单”的坑属性写了一堆、都不报错、都没有效果。而且这个问题不是某一个控件特有Button、AppCompatButton、CompoundButton这几个继承链上都有可能撞上。不管你是刚上手 Android 不久、还在被样式优先级绕晕还是已经写过不少界面、但一直用“把背景换成纯色”这种土办法糊过去的开发者把这块机制捋清楚都能省下大量试错时间。下面我就从“阴影是谁画的”开始一路讲到单控件处理、全局接管、触摸反馈补偿和几个高频踩坑尽量把能抄作业的部分都写细。1.1 一个反直觉的现象elevation 写 0 为什么不管用先把这个最容易误解的点说透。很多人包括我的第一反应是设置高程觉得阴影是由elevation产生的把它归零就完事了。逻辑没错但漏了一层在 Android 5.0 之后的渲染模型里真正决定阴影大小的是translationZ与elevation的叠加值合成后的 Z 值才是绘制阴影的依据。elevation是静态的基础高度相当于“这个控件离桌面有多高”而translationZ是动态的位移量用来做动画。默认的stateListAnimator干的事情就是按下时把translationZ推到 4dp 左右抬手之后延时回落到 0dp。所以你即使把elevation写成0dp只要手指一按translationZ一涨合成 Z 值立刻大于 0阴影照样冒出来。抬起手后回落到 0阴影消失看起来很“正常”于是很多人误以为是“点击态特效”没意识到这玩意儿平时也在影响布局观感。更隐蔽的一点是translationZ的动画是异步执行的你在代码里设置完属性立刻截图可能截到的是动画中间态。这就是为什么有些人反馈“我明明改了有时候有阴影有时候没有”——不是玄学是时序。注意elevation只影响阴影的“基础高度”真正让阴影在按下瞬间膨胀的是stateListAnimator。想彻底安静必须把这个动画源掐掉而不是只调基础高度。1.2 StateListAnimator 才是真正的源头StateListAnimator可以理解成“给控件状态配动画”的调度器控件处于不同状态按下、选中、启用/禁用时触发不同的属性动画。系统给按钮准备的默认实现在传统主题下是一个叫btn_default_material的动画集合里面主要是三条规则——按下时translationZ升到 4dp正常启用态回落到 0dp禁用态也归零。这里有个很关键的设计意图值得说明Material 设计规范希望用户能“摸到”控件的层次按钮默认浮起 2dp 左右按下时进一步抬升形成“按进去”的错觉。这套语义在规范里是加分项但落到具体的产品设计稿上经常是灾难——设计要扁平代码给立体两边对不上最后背锅的是开发者。还有一个容易被忽略的细节这套动画的生效前提是控件处于启用状态。如果你给按钮设了android:enabledfalse阴影反而会消失于是有人得出“禁用就没阴影了”的错误结论然后在业务里用禁用态做视觉结果点击逻辑全废。这个坑我在早期项目里踩过一次排查了半天才发现是状态机在作祟。2. 四种去阴影姿势各自的适用边界把源头找清楚之后解决方案其实就清楚了无非是“把动画源掐掉”“换一套不带动画的样式”“换成别的控件”“在主题层统一改”这几条路。但它们的副作用、适用范围、维护成本差别很大选错了会在后期反复返工。我按实际使用频率从高到低排一下并且说明每种方案在什么场景下该用、什么时候不该用。选型的时候我一般会问自己三个问题这个按钮是全局统一风格还是局部特例我需不需要保留按压反馈这个界面后续还会不会有人接手改样式这三个问题的答案基本就决定了走哪条路。全局统一 保留反馈走主题层局部特例 不要反馈走单控件属性中间态抽 style。2.1 直接掐掉动画源最直接也最需要补课最省事的写法是在布局里加一行android:stateListAnimatornull。它的语义很明确这个控件不需要任何状态动画。加上之后按下时translationZ不再变化阴影彻底消失而且不影响点击事件、不影响背景、不影响文字样式副作用最小。代码动态创建的时候对应的是setStateListAnimator(null)。注意这个方法在 API 21 才引入低于 21 的版本调用会崩所以要么加版本判断要么干脆不在低版本调低版本本来也没有这套阴影机制不调也不会出错。代价是什么按压反馈没了。原本按下去会有轻微的浮起/回落现在按下去和没按一样尤其在纯色按钮上用户会有“我到底点没点中”的疑惑。所以这条路必须配一个补偿方案比如 ripple 波纹、背景色变化、缩放动画后文会专门讲。2.2 换一套无海拔样式更符合框架设计意图第二种是换 style用Widget.AppCompat.Button.Borderless这类本身就不带海拔的样式或者自己继承一个基础样式再覆盖。这条路的好处是“顺着框架走”不会和系统默认行为打架后续升级兼容性更好。缺点是它同时会改掉背景和边框——Borderless 顾名思义是把背景也去掉了。如果你的设计稿是“有底色但无阴影”那就得在自己的 style 里把背景补回来。等于多做一步。我一般的做法是定义一个自己的基础按钮样式把stateListAnimator置空、elevation置 0、背景换成自定义 selector然后全项目继承它。这样只写一次后面所有按钮都干净。2.3 换成 TextView最粗暴但有时最有效有些团队干脆不用 Button直接拿TextView加点击事件来做按钮。TextView 没有默认的stateListAnimator天然没有阴影样式完全可控。对于那种“就是个可点击的文字块”的场景这确实是最省心的。但我不太推荐把它当通用方案。原因有三个一是失去了 Button 自带的无障碍语义屏幕阅读器会把它读成文本而不是按钮二是焦点态、键盘导航这些默认行为要自己补三是团队新人看到 TextView 带点击监听会犯嘀咕可维护性下降。真要用至少加个android:clickabletrue和android:focusabletrue把语义补回去。2.4 主题层统一接管长期项目的最优解如果整个 App 的设计语言就是扁平化那我建议直接在主题里把buttonStyle指向自己定义的样式。这样所有通过主题创建的按钮自动就是干净的不用每个布局文件都写一遍属性。这个方案的隐性收益在于一致性。项目做大了之后最怕的就是“这个页面的按钮有阴影、那个页面没有”用户看着别扭改起来还得全局搜。主题层接管一次后面加页面就不用再想这事。下面这张表是我自己在选型时会对照的速查表把四种方案的关键差异列出来方案改动位置是否保留按压反馈适用场景主要风险stateListAnimatornull单个控件否需自行补偿局部特例、临时改忘记补反馈点击感缺失自定义 style 继承style 文件取决于背景 selector中型项目、风格统一style 继承链写错导致背景丢失换 TextView布局文件否纯文字可点区域无障碍语义丢失主题buttonStylethemes 文件取决于样式内容全项目扁平化影响面大需回归测试2.5 顺手说一句 MaterialButton如果你的项目用了 Material 组件库按钮很可能是MaterialButton而不是AppCompatButton。它有自己的elevation体系和inset机制处理方式不太一样直接设app:elevation0dp通常有效因为它没有 AppCompat 那套默认的stateListAnimator。但如果你在布局里混用了两种按钮就会出现“有的有阴影有的没有”的割裂感。这种混用场景在后文排查章节里还会展开讲。3. 动手改从单控件到全局的三级实操讲完方案进入具体落地。我按改动范围从小到大分三级先改一个控件验证效果再抽成可复用样式最后在主题层接管。这样分级的好处是——每一步都能单独验证出问题能快速定位是哪一层引入的。上来就改主题、结果整个 App 的按钮全变样是最容易翻车的做法。3.1 第一级单个按钮的最小改动先拿一个按钮做实验验证机制判断是否正确。布局文件里这么写androidx.appcompat.widget.AppCompatButton android:idid/btn_confirm android:layout_widthmatch_parent android:layout_height48dp android:backgrounddrawable/bg_btn_primary android:elevation0dp android:stateListAnimatornull android:text确认提交 android:textColor#FFFFFF /关键就两行stateListAnimatornull掐掉动画源elevation0dp把基础高度也压平。两个都写是有原因的——只写前者某些定制 ROM 或第三方主题可能给按钮预设了非零的elevation阴影还会以静态形式存在只写后者前面已经解释过按下时照样冒。跑起来之后建议做三件事验证静态下看有没有阴影、按住不放看有没有阴影、长按抬起看有没有残留。第三个最容易漏因为动画回落是有延时的如果stateListAnimator没真正置空你会在抬起后短暂看到一圈阴影残留。提示验证的时候别只看模拟器。模拟器渲染和真机尤其是不同厂商的定制系统在阴影绘制上有细微差异我遇到过一次模拟器干净、真机有淡淡一圈的情况最后查到是 ROM 自带的按钮样式覆盖了默认值。3.2 第二级抽一条可复用的皮肤线单控件验证通过之后就该抽样式了。我习惯建一个styles_buttons.xml把按钮的公共属性集中管理resources style nameWidget.App.Button.Flat parentWidget.AppCompat.Button item nameandroid:stateListAnimatornull/item item nameandroid:elevation0dp/item item nameandroid:backgrounddrawable/bg_btn_flat/item item nameandroid:textAllCapsfalse/item item nameandroid:minHeight48dp/item item nameandroid:minWidth0dp/item item nameandroid:paddingStart16dp/item item nameandroid:paddingEnd16dp/item /style style nameWidget.App.Button.Flat.Outline item nameandroid:backgrounddrawable/bg_btn_outline/item item nameandroid:textColorcolor/brand_primary/item /style /resources这里有几个细节值得单独说。textAllCaps设为 false 是因为 AppCompat 主题默认会把按钮文字转大写中文倒是不受影响但一旦有英文或数字就会变成全大写和设计稿对不上。minWidth设为 0 是因为系统默认给了 88dp 的最小宽度小尺寸按钮会被撑开。继承关系上我把Flat挂在Widget.AppCompat.Button下面而不是挂Borderless是为了保住背景这一层——Borderless 会把背景清掉我还得再补回来多绕一圈。挂父类之后我只需要覆盖想改的属性剩下的交给系统。样式抽好之后布局里就清爽了androidx.appcompat.widget.AppCompatButton stylestyle/Widget.App.Button.Flat android:layout_widthmatch_parent android:layout_heightwrap_content android:text确认提交 /后面新增按钮只需要换 style 名属性一个都不用重复写。这套做法在团队协作里收益很明显——设计改一次圆角改一个 drawable 文件就行不用全局搜索替换。3.3 第三级主题层统一接管到了全项目统一风格的阶段就该动主题了。在themes.xml里指定buttonStyleresources style nameTheme.App.Base parentTheme.AppCompat.Light.NoActionBar item namebuttonStylestyle/Widget.App.Button.Flat/item item nameandroid:buttonStylestyle/Widget.App.Button.Flat/item item namematerialButtonStylestyle/Widget.App.Button.Flat/item /style /resources三个属性分别对应不同层级的按钮创建路径。buttonStyle是 AppCompat 的android:buttonStyle是框架原生的materialButtonStyle是 Material 组件库的。三个都写上基本能覆盖住项目里绝大多数按钮创建方式包括那些通过Button标签自动转成 AppCompat 的情况。这里必须要提醒一句主题层改动的影响面是全量的。改完之后不要只点两个页面就发布至少要回归一遍普通按钮、禁用态按钮、带图标的按钮、对话框里的按钮、列表项里的按钮。我见过一次因为对话框走了独立的主题buttonStyle没继承到导致弹窗里的按钮还带着阴影和主界面风格割裂。3.4 代码动态创建时的处理有些按钮是运行时 new 出来的比如动态表单、动态列表项。这种就没法靠 XML style 了得在代码里补val button AppCompatButton(context).apply { setBackgroundResource(R.drawable.bg_btn_flat) stateListAnimator null elevation 0f minWidth 0 minimumHeight resources.getDimensionPixelSize(R.dimen.btn_min_height) isAllCaps false text 动态按钮 }注意stateListAnimator的赋值必须在 API 21 及以上低版本直接调会抛异常。稳妥的写法是加一层判断if (Build.VERSION.SDK_INT Build.VERSION_CODES.LOLLIPOP) { button.stateListAnimator null }elevation属性同理21 之前赋值也会崩虽然编译期不报错。这类问题在低版本设备上才暴露测试覆盖不全的话很容易漏。4. 阴影没了按压反馈得补回来掐掉stateListAnimator之后最直接的副作用就是按钮“死”了——按下没有任何视觉变化。这在触屏上是很大的体验损失用户会反复点、怀疑没点上。所以去阴影和补反馈必须是配套的只做一半等于把一个体验问题换成了另一个体验问题。补反馈的方式我常用的有三类波纹ripple、背景态切换selector、缩放动画。三者可以叠加也可以单选取决于设计稿的调性。经验上扁平风格配 ripple 最自然拟物风格配 selector 更合适强调交互的可以加轻微缩放。4.1 用 ripple 补回按压波纹ripple 是 Material 体系里最标准的按压反馈实现成本也最低。在drawable-v21目录下建一个bg_btn_flat.xmlripple xmlns:androidhttp://schemas.android.com/apk/res/android android:color#33FFFFFF item shape android:shaperectangle solid android:colorcolor/brand_primary / corners android:radius8dp / /shape /item /ripple外层ripple定义波纹的颜色内层item定义静止状态的形状。这样点下去会从触摸点扩散出一圈半透明白抬起后消散视觉上很接近系统按钮的反馈但完全没有阴影。要注意 ripple 必须放在drawable-v21或drawable但限定 API 21目录下否则低版本解析会出问题。低版本走的是drawable目录下的 selector 版本两套资源并存是标准做法。注意ripple 的波纹颜色建议用白色或黑色的低透明度20% 上下不要用纯色。纯色波纹在高饱和背景上会显得很脏而且和文字颜色打架。4.2 用 selector 做背景态切换低版本没有 ripple或者设计上就不想要波纹效果有些偏商务风的产品觉得波纹太“跳”那就用传统的 selectorselector xmlns:androidhttp://schemas.android.com/apk/res/android item android:state_pressedtrue shape android:shaperectangle solid android:colorcolor/brand_primary_pressed / corners android:radius8dp / /shape /item item android:state_enabledfalse shape android:shaperectangle solid android:colorcolor/brand_primary_disabled / corners android:radius8dp / /shape /item item shape android:shaperectangle solid android:colorcolor/brand_primary / corners android:radius8dp / /shape /item /selector状态顺序很重要state_pressed必须排在state_enabled前面否则禁用态会覆盖按下态出现“禁用按钮还能点出反馈”的怪现象。这是 selector 的匹配规则决定的——从上往下匹配命中第一条就停。同样别忘了禁用态。去掉stateListAnimator之后禁用按钮既没有阴影也没有按压反馈如果背景色还不做区分用户根本看不出这个按钮点不了。这个细节在表单校验场景里特别关键。4.3 圆角 阴影外溢的组合坑有一种情况很典型设计稿要求圆角按钮你按上面的方式做了结果圆角外面还是能看到一圈方形的阴影边缘。这不是stateListAnimator没清干净而是父布局的问题。Android 的阴影绘制默认会被父容器的绘制区域裁剪但如果你在父容器上设了android:clipChildrenfalse子控件的阴影就可以画到父容器边界外形成“方形投影框”。反过来如果父容器裁剪了圆角外的阴影也会被切掉一部分看着像缺角。我的处理经验是只要按钮已经是纯扁平的就把所有层级的elevation都清干净包括父容器。父容器如果为了做别的效果比如卡片浮起保留了 elevation那子按钮的阴影会叠加在卡片阴影上视觉上更难清理。这类问题排查起来很绕因为你在按钮上找不到任何异常。5. 踩坑实录与排查速查表这部分是我这些年在这个问题上攒下的“病历本”。很多问题不是技术难而是信息不透明——你改了属性、没报错、没效果只能靠猜。下面按症状归类给出可能原因和验证方式尽量让你少走弯路。5.1 阴影还在的六种可能症状可能原因验证方式静态下就有阴影elevation未清零查看控件是否有非零 elevation 或主题预设静态干净按下有阴影stateListAnimator未清临时置 null 再跑一次圆角外有方形投影父容器clipChildrenfalse或 elevation 叠加逐层清空父容器 elevation只有部分页面有阴影主题未覆盖到该页面检查页面是否用了独立主题长按抬起有残留动画回落延时观察抬指后 100-200ms 的变化真机有、模拟器无ROM 定制样式覆盖换一台不同厂商设备复现这张表我一般会先过一遍八成的情况能直接定位到。剩下的两成通常是更深层的问题比如自定义 View 继承了 Button 又重写了绘制逻辑那就得进源码层看了。5.2 与 MaterialButton 混用的割裂前面提过MaterialButton的阴影机制和AppCompatButton不一样它走的是自己的app:elevation和内部 inset 体系。如果项目里两种按钮混用很容易出现“同一个界面两个按钮一个有阴影一个没有”的尴尬。处理办法有两个方向一是统一技术栈全部换成MaterialButton然后用app:elevation0dp和app:insetTop0dp、app:insetBottom0dp压平二是统一回AppCompatButton走前面讲的 style 路线。我倾向后者因为MaterialButton的 inset 机制在低高度场景下会引入额外的布局计算容易和固定高度打架。如果非要混用至少要保证视觉一致把两者的elevation、stateListAnimator、background都手动对齐。这是个持续维护成本能避免就避免。5.3 低版本兼容的几个坑API 21 以下没有stateListAnimator这个概念也就没有那套阴影动画所以去阴影的代码在低版本上是多余的。但如果你不加版本判断直接调用会直接崩。这个坑在灰度测试阶段特别容易漏因为现在大部分测试机都是高版本。另一个坑是elevation属性。在 21 以下XML 里写android:elevation会被忽略不报错代码里赋值会抛异常。也就是说XML 写法是安全的代码写法必须判断版本。还有 ripple 资源。放在drawable-v21里低版本会自动回落到drawable目录。如果你的项目只建了drawable-v21没建drawable低版本会找不到资源背景变成默认灰色。这个现象很有迷惑性因为不报错只是“变丑了”。6. 几个提升质感的延伸做法去阴影这件事做完之后往往会引出新的设计需求既然不要阴影了那层次感怎么表达这其实是扁平化设计绕不开的问题。我总结了几个实际用下来比较稳的做法供参考。6.1 用描边和色阶替代阴影阴影的本质是表达层次扁平化里可以用描边或者背景色阶来替代。比如卡片用 1px 的浅灰描边代替投影按钮用同色系深浅两档来区分主次。这种做法的好处是渲染成本低描边不涉及阴影层计算而且在深色模式下表现更稳定——深色模式下阴影几乎不可见描边反而更清晰。具体写法就是用 shape 的stroke属性shape xmlns:androidhttp://schemas.android.com/apk/res/android android:shaperectangle solid android:colorcolor/surface / stroke android:width1dp android:colorcolor/outline / corners android:radius8dp / /shape描边宽度建议 1dp颜色比背景深一档即可太深会显得生硬。6.2 clipToOutline 与圆角裁剪的配合如果确实需要保留一点阴影比如悬浮操作按钮又想保持圆角可以用clipToOutline配合outlineProvider来做androidx.appcompat.widget.AppCompatButton android:layout_widthwrap_content android:layout_height48dp android:backgrounddrawable/bg_btn_round android:clipToOutlinetrue android:elevation4dp /clipToOutline会让控件的绘制被自身轮廓裁剪配合 shape 的圆角阴影也会跟着圆角走。这个属性能解决一部分圆角阴影外溢的问题但不是全部——如果父容器不裁剪阴影还是可能画出去。6.3 过度绘制与性能上的考量从性能角度看去掉stateListAnimator其实是有收益的每次按下抬起都触发属性动画在多按钮的列表里累积起来不算小。特别是长列表滚动时如果每个 item 都有带阴影的按钮GPU 需要处理大量阴影层合成帧率会有可感知的下降。我做过的粗暴对比是一个 200 项的列表每项一个带阴影按钮滚动时掉帧明显换成无阴影样式后滚动帧率稳定很多。当然这个对比没做严格测量仅供参考但方向是对的——阴影是渲染成本里比较贵的一环能用扁平解决的场景尽量扁平。最后分享一个我最近才注意到的细节stateListAnimatornull在某些旧版本 AppCompat 库上会有兼容问题表现为低版本崩溃或高版本无效果。如果你的项目依赖的 AppCompat 版本比较老建议先用代码设置setStateListAnimator(null)验证再决定是否在 XML 里写。另外改完主题层的buttonStyle之后记得把项目里那些手写过android:background的按钮也扫一遍——它们会覆盖主题样式里的背景但不会覆盖stateListAnimator结果就是“背景是新的、阴影还是旧的”这种半新半旧的状态比全旧更难排查。
阅读完成 · 觉得有帮助?