做Unity游戏界面,最难的不是把按钮摆上去、把图标塞进列表,而是你做得挺完美的东西,换个手机型号就变得七零八落。竖屏变横屏、刘海屏多了一条黑边、平板上的按钮大得离谱、模拟器上一套分辨率到了真机又是另一套,这些问题我在项目里反复踩过,而且每次都不是"改一个参数"就能收场的。你要真正搞定,得同时理解两个层面:一个是界面形状,另一个是分辨率适配。这两个东西看起来分开,其实是一件事——屏幕的物理形状和逻辑分辨率决定了UI能怎么排、怎么裁、怎么调。
这篇文章我打算把这套东西完整展开讲一遍:Canvas Scaler到底要怎么配、异形屏安全区怎么处理、自定义形状的UI有哪些方案(Mask、Sprite Mask、UI Shader、动态Mesh)、以及一个从零到一的小案例怎么落地。适合正在做Unity客户端、或者做UI框架的开发者,尤其是那种"不想每次加一个界面都被分辨率折腾一遍"的人。我会尽量把原理讲清楚,因为只有懂了原理,你才敢自己动手扩展,而不是永远在复制别人写的适配组件。
1. 界面适配和形状自定义,本质是在解决什么问题
1.1 分辨率问题的本质是"屏幕比例变了,坐标系没变"
很多新手一上来就问"分辨率怎么设置",其实这个说法本身就带着误解。你在Unity编辑器里把Game视图切个分辨率,它做的事只是改一下窗口尺寸,但你的Canvas到底怎么缩放、怎么定位,完全由Canvas Scaler说了算。也就是说,分辨率不是一个"设置项",而是你的UI系统需要应对的输入变量——不同设备传来的Screen.width和Screen.height完全不一样,你要做的是让UI布局对这个变量不那么敏感。
这里有个关键点:Unity UI的坐标单位是"逻辑像素",不是物理像素。在默认情况下,1个UI单位等于1个屏幕像素,但这是建立在你的Canvas Scaler选择Constant Pixel Size(恒定像素大小)的前提下的。一旦你把UIScale设置为Scale With Screen Size,逻辑坐标系就会根据参考分辨率缩放,所以"分辨率变了"不再直接等于"UI变大了",而是变成了"UI的缩放比例变了"。这个缩放比例的算法,就是Canvas Scaler里matchWidthOrHeight做的事情,后面我会专门讲。
真正让你头疼的,往往是宽高比的变化,而不是分辨率数值的变化。同样是16:9的1920x1080和1280x720,UI可以等比缩放,问题不大。但一旦从16:9换到20:9(很多挖孔屏)、从竖屏换到横屏、或者从手机换到平板,UI就必须重新排列,而不是单纯缩放。这也是为什么我习惯把"适配"拆成两部分看:数值不同用缩放,比例不同用布局。
1.2 形状问题不只是"圆形头像",还有安全区和不规则点击
"自定义游戏界面形状"这个需求,往小了说,是美术想做几个圆形头像、异形卡片、或者那种带尖角的对话框气泡;往大了说,是整个UI被限制在某个不规则范围内,比如环形屏幕、带刘海的屏幕、甚至车载中控那种长条屏幕。这两类需求的技术路径完全不同,千万别混为一谈。
圆形的头像框,其实就是一个Image配上Mask组件,把方形图片裁成圆形,这部分很简单。但这种"裁切"是一个视觉表现,它不改变UI元素的矩形位置和大小。也就是说,如果你做一个圆形的按钮,点击区域默认还是矩形的,这个坑很经典——看起来是圆形,点四个角也能点到,你要是没有处理碰撞区域,玩家就会觉得很怪。
不规则屏幕的安全区就比较麻烦了。Android的挖孔屏、iPhone的刘海屏,都有自己的一套安全区域API,Unity里对应的是Screen.safeArea。如果你不做处理,竖屏时UI会被刘海和底部Home条挡住,横屏时左右两边会留下奇怪的黑色或者被遮挡。这不是缩放能解决的,必须通过代码动态调整UI的根节点边距。
1.3 我常用的技术栈总览
这里先给一个总览,后面每一块都会细讲。我的技术栈大致是:Canvas Scaler负责全局缩放;RectTransform + Anchor控制布局位置;SafeArea组件处理异形屏;Image的九宫格处理拉伸不变形;Mask和RectMask2D做简单裁切;自定义Shader处理圆角、边框、多边形等复杂形状;如果美术需求极其特殊,比如要做一个不规则路径的"地图窗口",那就直接用VertexHelper动态生成UI网格。这套组合基本能覆盖我能想到的所有"界面形状"和"分辨率适配"需求。
2. 分辨率适配的底层原理与实操配置
2.1 Canvas Scaler三种模式,到底怎么选
Canvas Scaler是Unity中决定UI缩放的核心组件,它挂在Canvas根节点上,有几种模式,但实际项目中常用的也就三种,我一个个说清楚。
Constant Pixel Size,恒定像素大小。这种模式下UI不随屏幕大小缩放,1个UI单位始终等于1个物理像素。优点是逻辑简单,缺点是一旦屏幕分辨率低,UI就挤成一团;分辨率高,UI就变得很小看不清。这种模式只适合不做跨设备适配的场合,比如纯编辑器工具窗口、或者游戏内的固定分辨率渲染目标,正常项目我不会拿它来做主UI。
Scale With Screen Size,按屏幕大小缩放。这是主流选择。你需要设定一个参考分辨率,比如1920x1080,Unity会拿当前实际分辨率与你设定的参考分辨率做对比,计算出缩放系数。关键参数是matchWidthOrHeight(匹配宽度或高度),它决定缩放时以哪个维度为准。设为0表示完全匹配宽度,设为1表示完全匹配高度,0到1之间表示混合。简单理解:竖屏游戏我一般把参考分辨率设成竖屏,然后把matchWidthOrHeight设为1,这样高度是硬参考,宽度变化靠布局拉伸;横屏游戏反过来,或者取一个折中值。
Constant Physical Size,恒定物理大小。这种模式让UI元素在物理上保持一样大,也就是在不同PPI的设备上,UI的"物理尺寸"不变,比如一个13毫米高的按钮,在任何手机上都是13毫米。它的问题在于会引入DPI动态变化,性能更差,而且不同设备的响应可能非常奇怪,我基本只在做纸质说明书类的工具项目时才考虑。
| 模式 | 适用场景 | 核心问题 | 我的选择 |
|---|---|---|---|
| Constant Pixel Size | 编辑器工具、固定分辨率 | 跨设备必崩 | 不选 |
| Scale With Screen Size | 手机、PC游戏主UI | 需要控制宽高比匹配 | 主流方案 |
| Constant Physical Size | 特殊硬件、印刷类模拟 | 性能差、行为不稳定 | 很少用 |
2.2 参考分辨率怎么定,matchWidthOrHeight的取舍
选定Scale With Screen Size后,第一件事是设定Reference Resolution。我见过很多团队直接填1920x1080,然后做了一个月发现竖屏手机上UI完全没法看——因为美术给的图是横屏的,参考分辨率也是横屏的。你要做竖屏游戏,参考分辨率就应该定竖屏,做横屏游戏就定横屏,这是常识,但真的很多人会搞错。
接下来是matchWidthOrHeight的具体理解。这个值的含义是:缩放系数 = 根据当前屏幕宽高比,在"以宽为基准的缩放因子"和"以高为基准的缩放因子"之间做插值。注意,它不是简单地平均两个因子,而是对两个因子做指数插值再取对数,实际效果是:设为0时,UI的宽度方向始终占满屏幕;设为1时,UI的高度方向始终占满屏幕;在0.5时,UI的缩放会让它在两种极端之间取折中,这时候屏幕两侧可能会有超出部分,需要通过布局去处理。
我的实操建议是,不要把一个matchWidthOrHeight值用到底。竖屏游戏大多设为1,横屏游戏大多设为0,如果游戏要同时支持竖屏和横屏,那你必须动态切换这个值——在脚本里根据Screen.width和Screen.height的比值,决定当前该用0还是1。我实测过很多方案,最终做法是:写一个简单的脚本,OnRectTransformDimensionsChange里监听Canvas的尺寸变化,然后动态设置matchWidthOrHeight。这样至少能保证UI在横竖屏切换时不会出现大面积乱飞。
2.3 SafeArea组件,异形屏最后的保命手段
异形屏的处理不是靠Canvas Scaler完成的,因为刘海和挖孔是屏幕物理形状造成的遮挡,逻辑分辨率并没有变,但可视区域变了。Unity在Screen.safeArea里给你提供了"安全区域"的矩形,单位是屏幕像素。你要做的,就是把这个矩形映射到UI根节点上。
我写过一个通用的SafeArea脚本,挂在UI根节点或者某个需要避让的父节点上,核心逻辑就几行:读取Screen.safeArea,然后根据Canvas Scaler的缩放比,把安全区的像素值转换成UI坐标下的offset,最后设置RectTransform的offsetMin和offsetMax。要注意的是,安全区不是永远不变的,手机横竖屏切换、折叠屏展开、Android的导航栏显示/隐藏,都会触发安全区变化,所以这个脚本必须监听屏幕变化事件,最好是每帧检测一次或者用Screen相关的事件回调。
这里有一个我在项目里反复踩的坑:如果把SafeArea组件直接挂在Canvas根节点上,它会改变整个Canvas的尺寸,这会反过来影响Canvas Scaler的适配基准,导致UI整体位置偏移。我的做法是,Canvas根节点保持全屏不动,Canvas下加一个安全的子节点,把SafeArea放在这个子节点上,后续所有UI都放在这个"安全区容器"里面。这样既有适配的参照系,又能避开异形区域。
2.4 UI相机和正交投影,别忽略分辨率对相机的影响
如果你的UI不是用Canvas渲染模式自带的屏幕空间相机,而是把Canvas放在World Space里,那分辨率适配就多了一个维度:正交相机的orthographicSize设置。这个size决定了相机的半高,也就是世界坐标里你看到的范围高度。屏幕宽度则由高度和宽高比共同决定。
举例来说,如果游戏的世界UI参考高度是10个单位,那么你把正交相机的size设为5(一半高度),屏幕宽度会根据当前屏占比自动变化。在16:9的屏幕上,看到的宽度是8.89个单位,在20:9的屏幕上,宽度就变成11.11个单位。如果你需要左右两端不露馅,就得把背景图拉得足够宽,或者做一个动态扩展的背景层。我习惯用脚本在Start或者分辨率变化时,根据Screen.width/Screen.height动态调整背景层的宽度,让世界UI不管在什么屏幕上都能铺满。
3. 自定义各种界面形状的四种主流方案
3.1 Mask和RectMask2D:简单形状的首选
Unity最基础的形状限制就是Mask组件,它放在一个带Image的UI节点上,然后用这张图作为模板,把子节点里超出模板的部分裁掉。比如你有一个方形图片,给它的父节点挂一个Mask,Mask的Image是圆形时,子节点就只显示圆形区域。这是实现圆形头像、三角形边框、星星形状卡片最直接的方式。
但Mask有个性能问题:它在内部需要为被遮罩的孩子生成一个额外的Stencil buffer操作,如果Mask的数量很多,或者Mask下面挂的UI元素特别多,DrawCall和填充率都会上升。更麻烦的是,Mask不支持一些特殊操作,比如子节点里有粒子系统或者非UI物体,处理起来会比较别扭。所以我的建议是:简单形状少量用Mask,大量的话选择RectMask2D或者自定义Shader。
RectMask2D是另一个方案,它的特点是只用矩形范围做裁剪,没有形状概念,但性能比Mask好,因为它的裁剪逻辑更简单,不依赖模板缓冲。适合那种"I只需要把超出某个矩形区域的都裁掉"的场景,比如列表滚动的时候隐藏超出列表框的内容。要注意的是RectMask2D只能处理矩形,不能处理圆形或三角形。
3.2 Sprite Mask:另一种遮罩,但用在UI上要慎重
Sprite Mask组件平时更多是配合2D Sprite渲染用的,它也可以用来遮罩UI,但效果和性能都不太理想。原因很简单,Sprite Mask默认是作用于整个场景的SpriteRenderer渲染管线的,你在UI里用它,会牵扯到UI渲染和Sprite渲染互相交叉,先不说渲染顺序乱不乱,单说它没法很好地和Masking Stencil机制配合,就足够让你头疼了。我的建议是:常规UI形状需求不要碰Sprite Mask,除非你是做2D场景里的技能指示器、地图视野遮罩,那它反而很合适。
3.3 自定义UI Shader:最优雅的形状方案
如果你要大量圆形、圆角、带边框的异形卡片,用Mask做不仅浪费性能,而且会限制很多效果。更优雅的方案是直接写一个UI Shader,让Image在绘制时根据UV坐标计算形状。比如给一个普通的UIImage挂一个带"圆角"片元函数的Shader,你在Inspector里设一个圆角半径,它就在四个角上画圆角矩形;设一个"内半径"值,它就能变成圆环。
这个思路的核心是:Fragment shader里通过判断像素坐标和形状边界的距离,决定这个像素是保留原颜色、变成透明、还是变成边框色。我用这种方式做过血条外框、技能冷却的弧形遮罩、六边形按钮,效果比Mask干净很多,而且不增加DrawCall。缺点是Shader的写法对团队有一定门槛,而且Unity的UI材质有合批要求——如果你给不同的UI元素挂不同的材质,合批就会被打断,所以要学会用MaterialPropertyBlock或者尽量共享材质,避免DrawCall爆炸。
下面是一个最基础的圆角矩形UI Shader的代码示例:
Shader "Custom/UIRoundRect" { Properties { [PerRendererData] _MainTex ("Sprite Texture", 2D) = "white" {} _Color ("Tint", Color) = (1,1,1,1) _Radius ("Corner Radius", Range(0,0.5)) = 0.1 _Stroke ("Stroke Width", Range(0,0.1)) = 0 _StrokeColor ("Stroke Color", Color) = (0,0,0,1) [HideInInspector] _StencilComp ("Stencil Comparison", Float) = 8 // 其他Stencil相关属性按Unity默认UI Shader补全 } SubShader { Tags { "Queue"="Transparent" "RenderType"="Transparent" "IgnoreProjector"="True" "PreviewType"="Plane" "CanUseSpriteAtlas"="True" } Cull Off Lighting Off ZWrite Off Blend One OneMinusSrcAlpha Pass { CGPROGRAM #pragma vertex vert #pragma fragment frag #include "UnityCG.cginc" struct appdata { float4 vertex : POSITION; float2 uv : TEXCOORD0; }; struct v2f { float4 vertex : SV_POSITION; float2 uv : TEXCOORD0; }; sampler2D _MainTex; float4 _MainTex_ST; fixed4 _Color; float _Radius; float _Stroke; fixed4 _StrokeColor; v2f vert (appdata v) { v2f o; o.vertex = UnityObjectToClipPos(v.vertex); o.uv = TRANSFORM_TEX(v.uv, _MainTex); return o; } fixed4 frag (v2f i) : SV_Target { // 把uv从[0,1]映射到[-0.5,0.5],方便距离计算 float2 p = i.uv - 0.5; float2 d = abs(p); // 圆角半径归一化 float r = _Radius; // 计算圆角距离场 float q = length(max(d - (0.5 - r), 0.0)) - r; // 圆角矩形内为-1,外为正数,用平滑步长做抗锯齿 float alpha = 1.0 - smoothstep(-0.001, 0.001, q); // 边框 float strokeAlpha = 0.0; if (_Stroke > 0.0) { float borderAlpha = 1.0 - smoothstep(-0.001, 0.001, abs(q) - _Stroke); strokeAlpha = borderAlpha; } fixed4 color = tex2D(_MainTex, i.uv) * _Color; color.a *= alpha; // 边框颜色叠加 color.rgb = lerp(color.rgb, _StrokeColor.rgb, strokeAlpha * color.a); // UI Shader需要预乘Alpha color.rgb *= color.a; return color; } ENDCG } } }这里我用了最简单的"距离场"思想:先算出当前像素离圆角矩形边界的距离,距离小于0表示在内部,再用smoothstep做一下抗锯齿,边缘看起来会平滑很多。边框就是算出边界距离后再判断一次绝对距离是否小于描边宽度。这种方式最大的优势是:不需要Mask,不需要额外的模板缓冲,只要Image的Sprite不透明,它就能画出一个完美的圆角矩形。我在项目里把这套Shader扩展成了一个可调圆角、内圆角、描边、渐变、弧形进度条的工具库,用起来非常舒服。
3.4 VertexHelper动态生成UI Mesh:终极自由
如果需要表现的形状完全不受限制,比如不规则的路径、环形血条、动态改变的多边形,那么Shader可能也不好做,这时候就该用VertexHelper直接改UI网格了。Unity的Image组件本身是可以用一个自定义的Mesh Geometry替换的,方法就是写一个继承自MaskableGraphic的自定义组件,在OnPopulateMesh函数里自己生成顶点和三角形。
这种做法最常见的使用场景是:动态绘制多边形按钮、雷达图、弧形进度条、或者带偏移的图文混排。因为你有完整的顶点生成逻辑,你想要多少边形、想让顶点位置随时间变化,都能控制。代价是需要对三角剖分和UV映射有一定的几何基础,否则很容易生成错乱的面片。
举一个简单例子,动态生成一个六边形按钮的轮廓:
using UnityEngine; using UnityEngine.UI; public class HexGraphic : MaskableGraphic { [SerializeField] private float radius = 50f; [SerializeField] private int sideCount = 6; protected override void OnPopulateMesh(VertexHelper vh) { vh.Clear(); // 中心顶点 UIVertex center = UIVertex.simpleVert; center.position = Vector2.zero; center.color = color; vh.AddVert(center); // 外围顶点 for (int i = 0; i <= sideCount; i++) { float angle = Mathf.PI * 2f * i / sideCount; Vector2 pos = new Vector2(Mathf.Cos(angle), Mathf.Sin(angle)) * radius; UIVertex vertex = UIVertex.simpleVert; vertex.position = pos; vertex.color = color; vertex.uv0 = new Vector2((pos.x / radius + 1f) * 0.5f, (pos.y / radius + 1f) * 0.5f); vh.AddVert(vertex); } // 中心三角形扇 for (int i = 0; i < sideCount; i++) { vh.AddTriangle(0, i + 1, i + 2); } } }这样生成出来的组件,你把它挂在UI下,它就能绘制一个六边形。配合代码动态修改sideCount和radius,就能让按钮在点击时变成八边形或者缩小,视觉效果很灵活。不过要提醒一点:动态Mesh组件不能和标准Image共享批次,所以在大量UI元素中,这种动态组件要控制数量,否则合批会被破坏。
3.5 扩展:图文混排和自定义校验里的"形状"问题
顺带提一个热词里出现的"图文混排"。Unity原生Text不支持文中插入图片,最多就是RichText标签改颜色。如果你要做对话系统里带表情、名称、物品图标的富文本,那就要自己扩展或者用第三方插件。图文混排最核心的难点就是:图片不是一个独立的个体,它要参与Text的排版——行高、换行、对齐都要处理。本质上这和你处理不规则UI图形的思路是相通的,都是要精确控制渲染区域和布局区域的关系。我见过很多团队的做法是在Text下面挂一个独立的图片节点,然后手动对齐它的位置,遇到过行数变化就乱七八糟。真正稳妥的方案是写一个自定义文本网格生成器,在生成每个字符的顶点时,同时为图片生成一个占位矩形,再把这个矩形区域的图片绘制出来。这个方案其实也是基于VertexHelper的,和动态Mesh那节是一套逻辑。
4. 完整实操:做一个"多边形状态卡片 + 自动适配分辨率"的小案例
4.1 需求描述和目标拆解
下面我用一个实际案例把前面讲的东西串起来。假设游戏里有一个背包里的装备卡片,卡片不是圆角矩形,而是六边形边框;卡片要适配不同分辨率和异形屏;卡片内部要显示图标、名称和属性数值,而且这些内容不能跑到六边形外。这种需求看起来简单,但结合形状+分辨率,很多团队会做成"图片渲染错位"或者"内容溢出"。
我先把目标拆解成三点:第一,卡片的轮廓是六边形,图片不能被裁出矩形边;第二,不管屏幕分辨率怎么变,卡片在屏幕上的相对位置和整体大小不能失控;第三,卡片内部文本区域要始终处于六边形内,不溢出。按照这个目标,我一个一个做。
4.2 第一步:Canvas和Canvas Scaler配置
我新建一个Canvas,渲染模式用Screen Space - Overlay(或者Screen Space - Camera,配合专门的UICamera)。在Canvas上挂Canvas Scaler,参考分辨率设置为设计稿的分辨率——这个项目美术提供的是竖屏1280x720(以宽为基准),所以我直接填Width: 720,Height: 1280,matchWidthOrHeight填0。为什么填0?因为这张设计稿的UI是按宽度来对齐的,左右两边是固定边界,上下可以留空。如果画面转横屏,我会在代码里动态把它切换成1,但第一版先按竖屏处理。
这里我额外说一句:很多人的设计稿可能是1920x1080横向,但你拿到手机上第一时间要做的就是确认游戏是竖屏还是横屏。竖屏游戏如果把参考分辨率设成横屏,那Canvas的宽度参考就没有意义,所有布局都会被压缩。
4.3 第二步:用自定义Shader实现六边形卡片
我在项目里没有选择Mask,因为这张卡片的轮廓不是UI图片的透明区域,而是需要动态加边框、加渐变、加高光的。Mask只能裁,不能画边框。所以我写了一个六边形UI Shader,核心思想是:在Fragment阶段算出当前UV点在不在六边形内,再根据到边的距离画描边。
六边形的距离场比圆角矩形稍微复杂一点。最简单的方式是把六边形拆成六个三角形区域,分别判断点是否在三角形内。你可以用一把很简单的公式:把点转换到六边形的局部坐标系,然后依次判断六个边的半平面。我不想把代码拉太长,重点告诉大家思路——核心是"判断点是否在多边形内+计算点到多边形边缘的距离",前者负责显示,后者负责画边框和抗锯齿。
4.4 第三步:编写适配脚本和内容布局
因为卡片要自动适配分辨率,我把卡片的根节点放在"安全区容器"内,然后给卡片定义了一个Anchor。竖屏时,卡片固定在中部偏左的位置,用Anchor把左下角拉到屏幕的一半处。这样做的好处是,我在代码里只需要设置RectTransform.anchoredPosition,不用关心屏幕尺寸。
实际的布局代码我会这样写:
using UnityEngine; using UnityEngine.UI; public class EquipmentCard : MonoBehaviour { public RectTransform cardRoot; public Text nameText; public Text attrText; public Image iconImage; public void Setup(string cardName, string attrs, Sprite icon) { // 直接设置文本 nameText.text = cardName; attrText.text = attrs; iconImage.sprite = icon; // 因为卡片是多边形,内容区域需要进行收缩,避免文字溢出六边形边界 float safePadding = 20f; // 这个值根据卡片半径算 iconImage.rectTransform.offsetMax = new Vector2(-safePadding, -safePadding); iconImage.rectTransform.offsetMin = new Vector2(safePadding, safePadding); } }这段代码里的safePadding是个关键经验值。六边形和矩形不一样,越靠近边缘越窄,所以你不光要留出常规边距,还得让图标中心往中心靠。如果你不做这个收缩,内容看起来会像"被挤到边缘外"一样。这里的设计思路和SafeArea是一样的:形状限制会吃掉一部分可用布局空间,所以内容布局必须为形状让路。
4.5 第四步:真机多分辨率预览和检查清单
配置完成后,我在编辑器里先切了几个预设分辨率,比如1920x1080、2400x1080、2560x1440、1080x2400,大致看一遍布局有没有失控。然后上真机,重点检查三个地方:刘海屏上下是否遮挡、卡片边缘是否贴边、文字是否溢出。我自己的检查顺序是:先看Canvas Scaler的匹配值,再看安全区容器的offset是否生效,最后看Shader的边缘是否锯齿。基本上只要这几项稳定,UI就能扛住大多数设备。
5. 常见问题与排查技巧实录
5.1 分辨率切来切去,UI黑屏或者错乱
这个问题基本不是你代码的Bug,而是Canvas的RenderMode和Camera配置冲突。最常见的是Screen Space - Camera模式的Canvas,其EventCamera或UICamera的TargetDisplay设置不对;要么是切换分辨率时相机没刷新视口。还有一个很容易踩的坑是,你在脚本里直接改Screen.SetResolution,但UI Canvas的缩放值并没有随分辨率变化实时更新,这个需要确保CanvasScaler组件没有被禁用,并且需要手动触发Canvas.ForceUpdateCanvases。我排查这个问题时,第一件事是打开Profiler看Canvas的Rebuild耗时,第二件事是检查Canvas Group的alpha和Canvas上的Sorting Order,基本上80%的黑屏问题都能在这两步里暴露。
5.2 Mask边缘锯齿、发虚怎么办
如果你用了Mask做圆形头像,边缘发虚是常态。原因是Mask只是做了一个Stencil裁剪,如果原始图片的分辨率不够高,边缘没有AA信息,你看到的就是锯齿。要解决,不外乎两招:一是放大图片分辨率,让边缘有足够像素做抗锯齿;二是在Mask这张Shapes图片上做边缘软化,把图片的边缘从硬边改成半透明渐变。这个在Photoshop里处理很简单,把圆形图周围做几像素的羽化就行。经过羽化的Mask,裁剪出来的边缘会柔和很多,代价是边缘会比设计稿虚一点,你需要自己调一个平衡度。
5.3 异形屏上按钮被刘海挡住
这个问题的根源是忘了加SafeArea。我在项目里见过很多人只在启动时读取一次安全区,没有处理屏幕旋转或者折叠屏展开的情况。解决方案就是我前面提的:把SafeArea逻辑写成一个持续监听的组件,并且放在UI父级容器上。还有一个细节:部分Android设备的剪切区域不是在SafeArea里返回的,而是通过cutout API,Unity高版本已经封装了Screen.cutout,你需要在Android上额外判断一下。如果项目用的Unity版本比较老,建议升级或者自己用AndroidJavaObject调用DisplayCutout接口。
5.4 图文混排时点击区域和显示区域不一致
这个问题我在做聊天系统时遇到过。文字显示是正常的,但图片的位置永远差一点,点击消息链接也经常点不中。排查下来发现,问题是RichText的图片占位和实际图片节点的坐标没有同步。如果是自己扩展的文本网格,你必须保证矩形占位和图片渲染用的UV区间是一套数据,不能一边手动算一边用RectTransform自动排版。我的建议是:把图片节点放到文本节点的同一RectTransform下,然后通过代码在这个父节点下根据文本网格的占位矩形设置位置,而不是让图片节点独立存在。
5.5 常见问题速查表
| 现象 | 可能原因 | 排查顺序 |
|---|---|---|
| UI黑屏或错乱 | Canvas渲染模式和相机目标设置问题 | 检查CanvasScaler、Camera、ForceUpdateCanvases |
| Mask边缘锯齿 | Stencil裁剪无AA信息 | 检查图片分辨率和羽化边缘 |
| 按钮被刘海遮挡 | 缺少SafeArea或安全区更新不及时 | 检查Screen.safeArea监听、Android cutout |
| 文字溢出多边形 | 形状限制了内容布局但没做内缩 | 调整布局Padding,内容中心靠拢 |
| 图片位置不对 | 图文混排占位矩形不同步 | 检查Mesh生成和图片节点位置逻辑 |
| 分辨率切换后拉伸变形 | 九宫格设置错误或Image type不对 | 检查Image.type为Sliced或Filled |
6. 最后分享一个我自己一直在用的小工具
这篇文章写到最后,我想分享一个我自己写了很久的小工具。它不是某个现成的插件,就是你项目里的一个Utility脚本,核心功能就是"监听屏幕变化,动态调整CanvasScaler的安全区和匹配值"。它也不是很复杂,就是定期检查Screen.width、Screen.height、Screen.safeArea,如果发现变化,就把当前的安全区映射到一个"CanvasContanier"节点上,同时根据宽高比设置CanvasScaler的matchWidthOrHeight。有了这个工具,团队里任何新成员做的UI,只要放进这个容器里,跑在真机上基本不会出大乱子。我现在做新项目,第一个搭建的就是这个基础层,后面所有UI功能都在它上面生长。我自己最大的体会是:不要指望一个适配方案能解决所有问题,它只能给你兜底,真正的UI设计还是得有人为不同屏幕比例做主次决策。但只要形状和分辨率这两个底层问题稳住了,剩下的事就都是细节了。