Android 如何绘制贴合物理圆角屏幕边缘的 1 px 路径?
结论
Android 没有公开 API 可以把应用坐标精确映射到每台设备的物理玻璃轮廓,因此无法保证在所有设备上绘制出零间隙、完全贴合硬件边缘的 1 px 路径。
WindowInsets.getRoundedCorner() 返回系统提供的圆角近似信息,包括圆心和半径,可用于避让圆角或绘制近似轮廓。这些信息不一定能准确描述面板遮罩、玻璃切割形状或每个像素最终是否可见。设备边缘也未必是标准圆弧,还可能采用椭圆、连续曲率曲线或厂商自定义遮罩。
较为通用的处理方式如下:
- 同时使用
RoundedCorner.center和RoundedCorner.radius,不要只读取radius。 - 将路径中心线向屏幕内部移动至少半个描边宽度。
- 内缩圆弧时保持圆心不变,并相应减小半径。
- 将绘制结果视为系统支持的近似轮廓。若要在某台设备上实现视觉上的精确贴合,需要针对设备或机型进行校准。
为什么圆角弧线会被裁剪
Stroke(width = 1f) 以路径为中心绘制。路径落在边界上时,理论上会有 0.5 px 位于边界外:
strokeWidth = 1 px
outside = 0.5 px
inside = 0.5 px
直线即使被裁掉一半,通常仍有像素可见。圆角区域还会受到物理面板遮罩、系统圆角遮罩和抗锯齿采样的影响。靠外侧的采样点可能全部被丢弃,整段弧线也就看不见了。
软件画布的矩形范围也不等同于屏幕实际可发光的区域。应用可以在逻辑坐标中提交某个像素,显示系统仍可能在合成阶段根据面板形状将其裁掉。黑边、非发光区域以及物理玻璃以外的区域都无法由应用覆盖。
正确的内缩几何关系
假设系统报告的外轮廓是半径为 (R) 的圆弧,描边宽度为 (w),额外预留的抗锯齿安全距离为 (\varepsilon)。
路径中心线向内移动的距离为:
[ d = \frac{w}{2} + \varepsilon ]
内缩后的圆弧半径为:
[ R_{\text{inner}} = R - d ]
内缩时圆心不变。
对于标准圆角矩形,需要同时调整边界矩形和圆角半径:
bounding rect 向内缩 d
corner radius 从 R 改为 R - d
如果只缩小矩形,圆角半径仍使用原来的 R,得到的路径就不是外轮廓的等距曲线,圆弧与直线之间的连接关系也会发生变化。
对称圆角屏幕的 Compose 示例
以下写法适用于四个圆角可以近似采用同一半径,且画布确实覆盖整个窗口的情况:
@RequiresApi(Build.VERSION_CODES.S)
@Composable
fun EdgeBorder() {
val view = LocalView.current
Canvas(
modifier = Modifier
.fillMaxSize()
.background(Color.Black)
) {
val insets = view.rootWindowInsets ?: return@Canvas
val topLeftCorner = insets.getRoundedCorner(
RoundedCorner.POSITION_TOP_LEFT
) ?: return@Canvas
val strokeWidth = 1f
val antialiasSafety = 0.5f
val inset = strokeWidth / 2f + antialiasSafety
val outerRadius = topLeftCorner.radius.toFloat()
val innerRadius = (outerRadius - inset).coerceAtLeast(0f)
drawRoundRect(
color = Color.White,
topLeft = Offset(inset, inset),
size = Size(
width = size.width - inset * 2f,
height = size.height - inset * 2f
),
cornerRadius = CornerRadius(innerRadius),
style = Stroke(width = strokeWidth)
)
}
}
Stroke(width = 1f) 在 Compose 绘制坐标中表示 1 个物理像素,并非 1.dp。如果希望描边的视觉宽度不受屏幕密度影响,可以使用 1.dp.toPx(),但此时描边宽度不再固定为一个物理像素。
antialiasSafety 没有统一的标准值。设为 0f 时,中心线恰好内缩半个描边宽度。增加约半个像素可以降低边缘采样被遮罩裁掉的概率,代价是路径与屏幕边缘的距离会稍大。
使用圆心绘制单个圆角
圆心不一定是 (radius, radius)。窗口偏移、旋转、分屏和厂商配置都可能使这一假设失效。绘制左上角时,可以直接读取 RoundedCorner.center:
@RequiresApi(Build.VERSION_CODES.S)
fun DrawScope.drawTopLeftCorner(
corner: RoundedCorner,
color: Color,
strokeWidth: Float = 1f,
antialiasSafety: Float = 0.5f
) {
val inset = strokeWidth / 2f + antialiasSafety
val radius = (corner.radius.toFloat() - inset).coerceAtLeast(0f)
if (radius <= 0f) return
val centerX = corner.center.x.toFloat()
val centerY = corner.center.y.toFloat()
drawArc(
color = color,
startAngle = 180f,
sweepAngle = 90f,
useCenter = false,
topLeft = Offset(
x = centerX - radius,
y = centerY - radius
),
size = Size(
width = radius * 2f,
height = radius * 2f
),
style = Stroke(width = strokeWidth)
)
}
绘制完整边框时,应分别读取四个圆角:
RoundedCorner.POSITION_TOP_LEFT
RoundedCorner.POSITION_TOP_RIGHT
RoundedCorner.POSITION_BOTTOM_RIGHT
RoundedCorner.POSITION_BOTTOM_LEFT
根据每个圆角各自的 center 和 radius 构造 Path,再用直线连接圆弧的切点。四个角不能默认完全对称。
窗口和坐标系必须一致
只有当路径与圆角数据位于同一窗口坐标系时,这些数据才有意义。实现时需要确认:
- Canvas 覆盖当前窗口,而不是经过额外偏移或缩放的子布局。
- 窗口已按需求启用 edge-to-edge。
- 系统栏没有缩小实际绘制区域。
- 使用当前窗口收到的
WindowInsets,不要把其他显示区域的 metrics 直接用于局部 Canvas。 - 屏幕旋转、进入分屏、连接外部显示器或窗口尺寸变化后,重新读取四个圆角。
- 在不支持
RoundedCorner的系统版本上,使用普通矩形或安全内缩方案。
如果应用的 Surface 没有延伸到显示边缘,无论如何计算路径,内容都无法越过该 Surface 的边界。
为什么实测半径可能与 API 不一致
实测结果与 API 数据存在差异,通常有以下原因:
RoundedCorner用圆弧近似真实边缘,而硬件轮廓未必是圆。- 厂商上报的参数可能来自资源配置,与面板的逐像素遮罩并不完全一致。
- 物理玻璃边缘、有效发光区域和系统合成遮罩可能对应不同的边界。
- 抗锯齿会对路径两侧进行采样,外侧样本可能被最终遮罩丢弃。
- 模拟器显示的是软件模拟圆角,不能代表真实玻璃或面板的形状。
经验值 R = 68f 和 magentaD = 8f 只能作为特定设备的视觉校准结果。它们不能用于通用算法,也无法根据另一台设备的 RoundedCorner.radius 可靠推导。
实际应如何选择
如果需要避免内容被圆角遮挡,应使用系统 insets 并预留安全距离,不必尝试绘制到最后一个可见像素。
绘制装饰边框时,可以结合 center、radius 和 (R-d) 的内缩公式,获得跨设备表现较为稳定的近似轮廓。
如果产品要求边框与指定硬件的真实轮廓逐像素吻合,就需要按机型维护校准数据,或由设备厂商提供准确的轮廓信息。普通 Android 应用无法通过公开 API 自动还原任意设备的物理玻璃曲线。
备注:内容仅供参考。
