From 80b41821b793c149bf33a54846d700ed57156094 Mon Sep 17 00:00:00 2001 From: iPel Date: Mon, 15 Jun 2026 20:53:14 +0800 Subject: [PATCH 1/6] fix(android): border scale error on some older devices --- .../drawable/BackgroundDrawable.java | 117 +++++++++++++++++- .../drawable/BorderResolvedInfo.java | 27 ++++ 2 files changed, 142 insertions(+), 2 deletions(-) diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java index a416d41f940..f44f750abd2 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java @@ -143,8 +143,38 @@ protected void drawBackgroundColor(@NonNull Canvas canvas) { } if (mResolvedInfo.hasBorderRadius) { - assert mResolvedInfo.borderOutsidePath != null; - canvas.drawPath(mResolvedInfo.borderOutsidePath, paint); + // Prefer Canvas.drawRoundRect over Canvas.drawPath when the four corners + // share the same radius. + // + // drawRoundRect maps to a dedicated HWUI primitive on all supported API + // levels and is recorded as a vector command in the parent RenderNode's + // display list, so any ancestor transform (e.g. transform: scale()) is + // applied as a matrix transform at draw time and the result remains + // crisp at any scale. + // + // drawPath, in contrast, has historically been observed on some + // older HWUI versions to render arbitrary Path geometry through a + // path-tessellation cache: the tessellated result may be cached and + // reused, and on those versions an ancestor RenderNode being scaled + // later does not necessarily trigger a re-tessellation at the new + // effective scale, which manifests as blurry / aliased rounded + // corners. The exact mechanism, the affected version range and + // OEM coverage are all HWUI implementation details outside of our + // control, but the observable symptom is reliably absent when the + // same geometry is drawn via drawRoundRect. We therefore prefer + // drawRoundRect whenever the geometry can be expressed as a + // uniform-radius round rect. + float uniformRadius = mResolvedInfo.getUniformBorderRadius(); + if (uniformRadius >= 0) { + canvas.drawRoundRect(mRect, uniformRadius, uniformRadius, paint); + } else { + // Non-uniform per-corner radii cannot be expressed by drawRoundRect; + // fall back to the pre-built rounded-rect Path here. This branch may + // still be affected by the older-HWUI drawPath issue described + // above, but it is rarely hit in practice. + assert mResolvedInfo.borderOutsidePath != null; + canvas.drawPath(mResolvedInfo.borderOutsidePath, paint); + } } else { canvas.drawRect(mRect, paint); } @@ -187,6 +217,85 @@ protected void drawBorder(@NonNull Canvas canvas) { if (!mResolvedInfo.hasVisibleBorder) { return; } + // Fast path: the four sides share the same width / color / style (i.e. + // !drawBorderSideBySide) AND the four corners share the same resolved + // radius. Under these conditions the border can be expressed exactly as + // a single stroked round rect along the geometric midline of the band + // between borderOutsidePath and borderInsidePath, which lets us emit + // the border as one Canvas.drawRoundRect command. + // + // Why a fast path is needed: + // + // The general path below relies on + // canvas.clipPath(borderOutsidePath); + // canvas.clipPath(borderInsidePath, Region.Op.DIFFERENCE); + // canvas.drawPath(borderMidlinePath, STROKE); + // to confine the stroke to the border band. On some older HWUI + // versions, non-rectangular clipPath - and especially non-INTERSECT + // clipPath such as Region.Op.DIFFERENCE used here - is not + // accelerated directly on the GPU; instead the affected RenderNode is + // rendered into an intermediate offscreen surface so that the clip + // can be applied via per-pixel masking. Whether the fallback uses an + // HWUI layer, the path tessellation cache or another mechanism, and + // exactly which Android versions / OEM builds are affected, are HWUI + // implementation details outside of our control. What matters here + // is the observable effect: once the RenderNode's content has been + // rasterized at its natural size, an ancestor transform such as + // transform: scale(1.5) can only resample that raster, producing + // visibly blurry / aliased corners and a border that does not appear + // to scale together with its parent. This was originally reported + // on an API 24 device, with an API 31 device rendering correctly; + // those two data points are the only ones we have actually verified + // and should not be read as a precise affected-version range. + // + // Canvas.drawRoundRect, by contrast, is a first-class HWUI primitive + // on every supported API level and is recorded into the display list + // as a vector command, so it is unaffected by the issue above. + if (!mResolvedInfo.drawBorderSideBySide + && mResolvedInfo.hasBorderRadius + && mResolvedInfo.hasContentRadius + && mResolvedInfo.borderMidlineRect != null) { + float uniformRadius = mResolvedInfo.getUniformBorderRadius(); + if (uniformRadius >= 0) { + assert mPaint != null; + float strokeWidth = mResolvedInfo.strokeWidth.left; + // Geometry of the fast path: + // - borderMidlineRect is the outer rect inset by borderWidth/2 + // on each side, i.e. the centerline of the border band. + // - With strokeWidth = borderWidth, the STROKE expands by + // borderWidth/2 on each side of the midline, so the outer + // edge of the stroke lands exactly on borderOutsidePath + // and the inner edge lands exactly on borderInsidePath. + // - The midline radius is therefore (uniformRadius - + // borderWidth/2). Within this fast path we already require + // !drawBorderSideBySide (equal widths on all four sides) + // and a uniform corner radius, under which the + // hasContentRadius predicate (see + // BorderResolvedInfo#hasContentRadius) reduces to + // uniformRadius > borderWidth, so midlineRadius is + // strictly positive. The Math.max(0f, ...) is purely + // defensive against floating-point rounding when the two + // values are extremely close. + // The stroked round rect therefore tiles exactly the band + // that the general path would have produced via clipPath, + // with no clipping required. + float midlineRadius = Math.max(0f, uniformRadius - strokeWidth * 0.5f); + mPaint.setStyle(Paint.Style.STROKE); + mPaint.setStrokeWidth(strokeWidth); + mPaint.setColor(mResolvedInfo.borderColor.left); + mPaint.setPathEffect(mResolvedInfo.pathEffect.left); + canvas.drawRoundRect(mResolvedInfo.borderMidlineRect, + midlineRadius, midlineRadius, mPaint); + return; + } + } + // General path: per-side widths / colors / styles, non-uniform corner + // radii, or no rounded corners at all. Falls back to the historical + // clipPath + drawPath implementation. On older HWUI versions this path + // may still trigger the offscreen-rasterization behavior described + // above, but in those cases the geometry cannot be reduced to a single + // drawRoundRect; addressing it would require a more involved rewrite + // (e.g. building the border band as a closed Path and filling it). canvas.save(); if (mResolvedInfo.hasBorderRadius) { canvas.clipPath(mResolvedInfo.borderOutsidePath); @@ -441,6 +550,10 @@ public void setBorderRadius(@Px float radius, BorderArc arc) { LogUtils.w(TAG, "setBorderRadius: Unknown arc: " + arc); } } + + public boolean hasSameRadiusOnAllSides() { + return topLeft == topRight && topRight == bottomRight && bottomRight == bottomLeft; + } } public static class BorderColor { diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BorderResolvedInfo.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BorderResolvedInfo.java index 2be592d7c47..6d82739a922 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BorderResolvedInfo.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BorderResolvedInfo.java @@ -67,6 +67,33 @@ protected float[] initialValue() { private final BorderRadius borderRadius = new BorderRadius(0); private final BorderStyles borderStyles = new BorderStyles(BorderStyle.NONE); + /** + * Returns the resolved border radius, in pixels, shared by all four corners, + * or {@code -1f} when there is no border radius at all or when the four + * corners do not all share the same radius. + * + *

The value returned here is the radius after {@link + * #resolveBorderRadius(float, float, float, BackgroundDrawable.BorderRadius)} + * has potentially scaled the user-supplied radii down so that + * {@code (left + right)} and {@code (top + bottom)} corner radii fit within + * the rect bounds. Callers that draw geometry based on this value must + * therefore use it together with the resolved {@code mRect} / {@code + * borderWidth}, not with the raw values configured on {@code + * BackgroundDrawable}. + * + *

This method must be called after {@link #resolve}; otherwise + * the returned value reflects stale state from the previous resolve cycle. + */ + float getUniformBorderRadius() { + if (!hasBorderRadius) { + return -1f; + } + if (borderRadius.hasSameRadiusOnAllSides()) { + return borderRadius.topLeft; + } + return -1f; + } + void resolve(RectF rect, int preferBorderWidth, BorderWidth preferBorderWidths, float preferBorderRadius, BorderRadius preferBorderRadii, int preferBorderColor, BorderColor preferBorderColors, BorderStyle preferBorderStyle, BorderStyles preferBorderStyles) { From a6dbaf937d37c334e60914970309ca063b5a2e03 Mon Sep 17 00:00:00 2001 From: iPel Date: Tue, 16 Jun 2026 18:22:39 +0800 Subject: [PATCH 2/6] fix(android): content scale error on some older devices --- .../tencent/renderer/component/Component.java | 11 ++ .../renderer/component/FlatViewGroup.java | 108 ++++++++++++++++-- .../drawable/BackgroundDrawable.java | 86 +++++++++++++- .../drawable/BorderResolvedInfo.java | 53 ++++++++- 4 files changed, 240 insertions(+), 18 deletions(-) diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/Component.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/Component.java index 73e66202029..9fcf62deb67 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/Component.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/Component.java @@ -186,6 +186,17 @@ public Path getContentRegionPath() { return (mBackgroundDrawable != null) ? mBackgroundDrawable.getBorderPath() : null; } + /** + * Returns the resolved uniform border radius (in pixels) of the background, + * or {@code -1f} when there is no border radius or the four corners do not + * share the same radius. See + * {@link BackgroundDrawable#getUniformBorderRadius()} for the precise + * semantics. + */ + public float getUniformBorderRadius() { + return (mBackgroundDrawable != null) ? mBackgroundDrawable.getUniformBorderRadius() : -1f; + } + /** * Get background layer drawable * diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java index 1d9a6070a08..adbd6ad2ecf 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java @@ -18,7 +18,11 @@ import android.content.Context; import android.graphics.Canvas; +import android.graphics.Path; +import android.os.Build; +import android.view.View; import android.view.ViewGroup; +import android.view.ViewParent; import com.tencent.mtt.hippy.uimanager.RenderManager; import com.tencent.mtt.hippy.utils.LogUtils; @@ -87,6 +91,62 @@ protected int getChildDrawingOrder(int childCount, int i) { return (index < 0 || index >= getChildCount()) ? i : index; } + /** + * Returns {@code true} if {@code this} view, or any of its ancestors up + * to (but not including) the root view, has a non-identity transform + * applied via the View RenderNode properties + * ({@link View#getScaleX}, {@link View#getScaleY}, + * {@link View#getRotation}, {@link View#getRotationX}, + * {@link View#getRotationY}). + * + *

Why we walk the view tree instead of inspecting the canvas matrix: + * with hardware-accelerated rendering, the canvas passed into + * {@code dispatchDraw} is a per-RenderNode RecordingCanvas whose matrix + * only contains the local translations performed during the current + * RenderNode's display-list recording; the scale/rotation properties of + * an ancestor RenderNode are applied later, on the RenderThread, during + * composition, and never appear in this canvas's matrix. Walking the + * view tree's logical transform properties gives us the truthful + * "is anyone scaling me?" answer regardless of whether software or + * hardware rendering is in effect. + * + *

This is used to gate the {@link Canvas#saveLayer} fallback in + * {@link #dispatchDraw(Canvas)}: on older HWUI versions, a {@link + * Canvas#clipPath} command issued under an ancestor RenderNode that is + * later scaled does not necessarily compose correctly with that scale, + * producing visibly mis-clipped rounded corners. Forcing an offscreen + * layer for the duration of the children draw isolates the clip into a + * pre-rasterized buffer that is then resampled by the ancestor scale, + * which is the same way the ancestor scales every other pixel and + * therefore stays geometrically consistent. + */ + private boolean hasAncestorOrSelfTransform() { + final float epsilon = 1e-4f; + View v = this; + // Walk up until we hit a non-View parent (Window root) or null. + while (v != null) { + float sx = v.getScaleX(); + float sy = v.getScaleY(); + float rz = v.getRotation(); + float rx = v.getRotationX(); + float ry = v.getRotationY(); + if (Math.abs(sx - 1f) > epsilon + || Math.abs(sy - 1f) > epsilon + || Math.abs(rz) > epsilon + || Math.abs(rx) > epsilon + || Math.abs(ry) > epsilon) { + return true; + } + ViewParent p = v.getParent(); + if (p instanceof View) { + v = (View) p; + } else { + break; + } + } + return false; + } + @Override protected void dispatchDraw(Canvas canvas) { RenderNode node = RenderManager.getRenderNode(this); @@ -95,13 +155,45 @@ protected void dispatchDraw(Canvas canvas) { return; } boolean clipChildren = getClipChildren(); - canvas.save(); - if (clipChildren) { - Component component = node.getComponent(); - if (component != null && component.getContentRegionPath() != null) { - canvas.clipPath(component.getContentRegionPath()); - } else { - canvas.clipRect(0, 0, getRight() - getLeft(), getBottom() - getTop()); + Component component = node.getComponent(); + Path roundCornerClipPath = (clipChildren && component != null) + ? component.getContentRegionPath() : null; + // Decide whether we need to escape into an offscreen layer to + // guarantee that rounded-corner clipping composes correctly with any + // ancestor scale. We do this only when: + // 1. there is actually a rounded-corner clip to apply, AND + // 2. this view or one of its ancestors carries a non-trivial scale + // or rotation transform. + // In all other cases the legacy clipPath / clipRect path is kept + // verbatim, so non-transformed scenes pay zero extra cost. + // + // The layer scope covers exactly the children draw - dispatchDraw is + // invoked after onDraw, so the BackgroundDrawable shadow has already + // been recorded into the parent RenderNode's display list and is not + // intercepted by the layer. + boolean useOffscreenLayer = roundCornerClipPath != null + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && hasAncestorOrSelfTransform(); + int restoreCount; + if (useOffscreenLayer) { + // saveLayer with a null paint creates a transparent offscreen + // buffer sized to the current clip; subsequent draws are + // rasterized into that buffer and the buffer is composited back + // at restore time. The clipPath issued *inside* the layer is + // applied while rasterizing, before the ancestor scale resamples + // the buffer, so the resulting rounded-corner mask scales + // together with every other pixel. + restoreCount = canvas.saveLayer(0, 0, getRight() - getLeft(), + getBottom() - getTop(), null); + canvas.clipPath(roundCornerClipPath); + } else { + restoreCount = canvas.save(); + if (clipChildren) { + if (roundCornerClipPath != null) { + canvas.clipPath(roundCornerClipPath); + } else { + canvas.clipRect(0, 0, getRight() - getLeft(), getBottom() - getTop()); + } } } mDispatchDrawHelper.onDispatchDrawStart(canvas, node); @@ -111,7 +203,7 @@ protected void dispatchDraw(Canvas canvas) { mDispatchDrawHelper.drawNext(this); } mDispatchDrawHelper.onDispatchDrawEnd(); - canvas.restore(); + canvas.restoreToCount(restoreCount); } @Override diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java index f44f750abd2..9fd5f15b61b 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java @@ -23,6 +23,7 @@ import android.graphics.PathEffect; import android.graphics.RectF; import android.graphics.Region; +import android.os.Build; import androidx.annotation.ColorInt; import androidx.annotation.NonNull; import androidx.annotation.Nullable; @@ -117,6 +118,26 @@ public Path getBorderPath() { return mResolvedInfo.hasBorderRadius ? mResolvedInfo.borderOutsidePath : null; } + /** + * Returns the resolved border radius shared by all four corners (in pixels), + * or {@code -1f} when there is no border radius or the four corners do not + * share the same radius. + * + *

This is the radius after the BorderResolvedInfo resolve step + * has potentially scaled the configured radii to fit within the rect bounds, + * so callers that combine this value with the rect must use the same + * rect that was passed into {@link #updatePath()} ({@code mRect}). + * + *

Mainly intended to let parent {@code ViewGroup}s install a uniform + * round-rect {@link android.graphics.Outline} for clip-to-outline based + * rounded-corner clipping, which is unaffected by the older-HWUI + * {@code clipPath}-under-ancestor-scale issue. + */ + public float getUniformBorderRadius() { + updatePath(); + return mResolvedInfo.getUniformBorderRadius(); + } + protected void updatePath() { if (!mUpdatePathRequired) { return; @@ -289,14 +310,39 @@ protected void drawBorder(@NonNull Canvas canvas) { return; } } + if (mResolvedInfo.drawBorderSideBySide && shouldDrawSideBySideBorderAsFill()) { + drawBorderSideFillInternal(canvas, + mResolvedInfo.strokeWidth.left, + mResolvedInfo.borderColor.left, + mResolvedInfo.borderSideFill.left); + drawBorderSideFillInternal(canvas, + mResolvedInfo.strokeWidth.top, + mResolvedInfo.borderColor.top, + mResolvedInfo.borderSideFill.top); + drawBorderSideFillInternal(canvas, + mResolvedInfo.strokeWidth.right, + mResolvedInfo.borderColor.right, + mResolvedInfo.borderSideFill.right); + drawBorderSideFillInternal(canvas, + mResolvedInfo.strokeWidth.bottom, + mResolvedInfo.borderColor.bottom, + mResolvedInfo.borderSideFill.bottom); + return; + } // General path: per-side widths / colors / styles, non-uniform corner // radii, or no rounded corners at all. Falls back to the historical // clipPath + drawPath implementation. On older HWUI versions this path // may still trigger the offscreen-rasterization behavior described - // above, but in those cases the geometry cannot be reduced to a single - // drawRoundRect; addressing it would require a more involved rewrite - // (e.g. building the border band as a closed Path and filling it). - canvas.save(); + // above. When a rounded complex border cannot use the drawRoundRect + // fast path, explicitly isolate the border drawing into a layer on + // older Android versions so the outer and inner path clips are applied + // in the same local rasterization step. + boolean useLayer = shouldDrawComplexRoundedBorderInLayer(); + if (useLayer) { + canvas.saveLayer(mRect, null); + } else { + canvas.save(); + } if (mResolvedInfo.hasBorderRadius) { canvas.clipPath(mResolvedInfo.borderOutsidePath); if (mResolvedInfo.hasContentRadius) { @@ -343,6 +389,38 @@ protected void drawBorder(@NonNull Canvas canvas) { canvas.restore(); } + private boolean shouldDrawComplexRoundedBorderInLayer() { + return mResolvedInfo.hasBorderRadius && Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1; + } + + private boolean shouldDrawSideBySideBorderAsFill() { + return canDrawBorderSideAsFill(mResolvedInfo.strokeWidth.left, + mResolvedInfo.pathEffect.left, mResolvedInfo.borderSideFill.left) + && canDrawBorderSideAsFill(mResolvedInfo.strokeWidth.top, + mResolvedInfo.pathEffect.top, mResolvedInfo.borderSideFill.top) + && canDrawBorderSideAsFill(mResolvedInfo.strokeWidth.right, + mResolvedInfo.pathEffect.right, mResolvedInfo.borderSideFill.right) + && canDrawBorderSideAsFill(mResolvedInfo.strokeWidth.bottom, + mResolvedInfo.pathEffect.bottom, mResolvedInfo.borderSideFill.bottom); + } + + private boolean canDrawBorderSideAsFill(int strokeWidth, @Nullable PathEffect pathEffect, + @Nullable Path fillPath) { + return strokeWidth <= 0 || pathEffect == null && fillPath != null; + } + + private void drawBorderSideFillInternal(@NonNull Canvas canvas, int strokeWidth, int color, + @Nullable Path fillPath) { + if (strokeWidth <= 0 || fillPath == null) { + return; + } + assert mPaint != null; + mPaint.setStyle(Paint.Style.FILL); + mPaint.setColor(color); + mPaint.setPathEffect(null); + canvas.drawPath(fillPath, mPaint); + } + private void drawBorderSideInternal(@NonNull Canvas canvas, int strokeWidth, int color, @Nullable PathEffect pathEffect, Path borderPath, Path clipPath) { if (strokeWidth <= 0) { diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BorderResolvedInfo.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BorderResolvedInfo.java index 6d82739a922..f3fbfc8f256 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BorderResolvedInfo.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BorderResolvedInfo.java @@ -54,6 +54,7 @@ protected float[] initialValue() { final BorderColor borderColor = new BorderColor(Color.TRANSPARENT); final BorderSideValue borderSideClip = new BorderSideValue<>(); final BorderSideValue borderSideMidline = new BorderSideValue<>(); + final BorderSideValue borderSideFill = new BorderSideValue<>(); final BorderSideValue pathEffect = new BorderSideValue<>(); boolean hasVisibleBorder; boolean hasBorderRadius; @@ -427,6 +428,7 @@ private void updateBorderStrokeLeft(RectF rect) { } clip.lineTo(rect.left, rect.bottom); clip.close(); + updateBorderSideFill(BorderSide.LEFT, clip); strokeWidth.left = strokeSize; pathEffect.left = buildPathEffect(style, borderWidth.left, (int) strokeLength, roundStart, roundEnd); } @@ -485,7 +487,7 @@ private void updateBorderStrokeTop(RectF rect) { if (endRadius > borderWidth.top && endRadius > borderWidth.left) { float endDiameter = 2 * endRadius; // top-left inside arc - float endAngle = 90 - arcAngle(startRadius, borderWidth.left, borderWidth.top, 1); + float endAngle = 90 - arcAngle(endRadius, borderWidth.left, borderWidth.top, 1); clip.arcTo( rect.left + borderWidth.left, rect.top + borderWidth.top, @@ -494,7 +496,7 @@ private void updateBorderStrokeTop(RectF rect) { 270, -endAngle, false); // top-left midline arc float endMidlineAngle = style != BorderStyle.DOTTED ? 90 - : 90 - arcAngle(startRadius, borderWidth.left, borderWidth.top, 0.5f); + : 90 - arcAngle(endRadius, borderWidth.left, borderWidth.top, 0.5f); midline.arcTo( rect.left + borderWidth.left * 0.5f, rect.top + borderWidth.top * 0.5f, @@ -514,6 +516,7 @@ private void updateBorderStrokeTop(RectF rect) { } clip.lineTo(rect.left, rect.top); clip.close(); + updateBorderSideFill(BorderSide.TOP, clip); strokeWidth.top = strokeSize; pathEffect.top = buildPathEffect(style, borderWidth.top, (int) strokeLength, roundStart, roundEnd); } @@ -573,7 +576,7 @@ private void updateBorderStrokeRight(RectF rect) { if (endRadius > borderWidth.top && endRadius > borderWidth.right) { float endDiameter = 2 * endRadius; // top-right inside arc - float endAngle = 90 - arcAngle(startRadius, borderWidth.top, borderWidth.right, 1); + float endAngle = 90 - arcAngle(endRadius, borderWidth.top, borderWidth.right, 1); clip.arcTo( rect.right - endDiameter + borderWidth.right, rect.top + borderWidth.top, @@ -582,7 +585,7 @@ private void updateBorderStrokeRight(RectF rect) { 0, -endAngle, false); // top-right midline arc float endMidlineAngle = style != BorderStyle.DOTTED ? 90 - : 90 - arcAngle(startRadius, borderWidth.top, borderWidth.right, 0.5f); + : 90 - arcAngle(endRadius, borderWidth.top, borderWidth.right, 0.5f); midline.arcTo( rect.right - endDiameter + borderWidth.right * 0.5f, rect.top + borderWidth.top * 0.5f, @@ -602,6 +605,7 @@ private void updateBorderStrokeRight(RectF rect) { } clip.lineTo(rect.right, rect.top); clip.close(); + updateBorderSideFill(BorderSide.RIGHT, clip); strokeWidth.right = strokeSize; pathEffect.right = buildPathEffect(style, borderWidth.right, (int) strokeLength, roundStart, roundEnd); } @@ -661,7 +665,7 @@ private void updateBorderStrokeBottom(RectF rect) { if (endRadius > borderWidth.bottom && endRadius > borderWidth.right) { float endDiameter = 2 * endRadius; // bottom-right inside arc - float endAngle = 90 - arcAngle(startRadius, borderWidth.right, borderWidth.bottom, 1); + float endAngle = 90 - arcAngle(endRadius, borderWidth.right, borderWidth.bottom, 1); clip.arcTo( rect.right - endDiameter + borderWidth.right, rect.bottom - endDiameter + borderWidth.bottom, @@ -670,7 +674,7 @@ private void updateBorderStrokeBottom(RectF rect) { 90, -endAngle, false); // bottom-right midline arc float endMidlineAngle = style != BorderStyle.DOTTED ? 90 - : 90 - arcAngle(startRadius, borderWidth.right, borderWidth.bottom, 0.5f); + : 90 - arcAngle(endRadius, borderWidth.right, borderWidth.bottom, 0.5f); midline.arcTo( rect.right - endDiameter + borderWidth.right * 0.5f, rect.bottom - endDiameter + borderWidth.bottom * 0.5f, @@ -690,10 +694,47 @@ private void updateBorderStrokeBottom(RectF rect) { } clip.lineTo(rect.right, rect.bottom); clip.close(); + updateBorderSideFill(BorderSide.BOTTOM, clip); strokeWidth.bottom = strokeSize; pathEffect.bottom = buildPathEffect(style, borderWidth.bottom, (int) strokeLength, roundStart, roundEnd); } + private void updateBorderSideFill(BorderSide side, Path sideClip) { + Path sideFill; + switch (side) { + case LEFT: + sideFill = borderSideFill.left; + if (sideFill == null) { + borderSideFill.left = sideFill = new Path(); + } + break; + case TOP: + sideFill = borderSideFill.top; + if (sideFill == null) { + borderSideFill.top = sideFill = new Path(); + } + break; + case RIGHT: + sideFill = borderSideFill.right; + if (sideFill == null) { + borderSideFill.right = sideFill = new Path(); + } + break; + case BOTTOM: + sideFill = borderSideFill.bottom; + if (sideFill == null) { + borderSideFill.bottom = sideFill = new Path(); + } + break; + default: + return; + } + sideFill.set(sideClip); + if (hasBorderRadius && borderOutsidePath != null) { + sideFill.op(borderOutsidePath, Path.Op.INTERSECT); + } + } + private static boolean hasContentRadius(BorderWidth borderWidth, BorderRadius borderRadius) { return borderRadius.topLeft > borderWidth.left || borderRadius.topLeft > borderWidth.top || borderRadius.topRight > borderWidth.top || borderRadius.topRight > borderWidth.right From 491f33ea3e137f726ed9212514534414abde150c Mon Sep 17 00:00:00 2001 From: iPel Date: Tue, 16 Jun 2026 21:41:31 +0800 Subject: [PATCH 3/6] fix(android): fix review issues --- .../renderer/component/FlatViewGroup.java | 44 ++++++++----- .../drawable/BackgroundDrawable.java | 63 ++++++++++--------- 2 files changed, 60 insertions(+), 47 deletions(-) diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java index adbd6ad2ecf..5df333fa80b 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java @@ -35,6 +35,8 @@ public class FlatViewGroup extends ViewGroup { private static final String TAG = "FlatViewGroup"; + private static final float TRANSFORM_EPSILON = 1e-4f; + private static final int MAX_TRANSFORM_ANCESTOR_DEPTH = 20; private final DispatchDrawHelper mDispatchDrawHelper = new DispatchDrawHelper(); public FlatViewGroup(Context context) { @@ -113,28 +115,32 @@ protected int getChildDrawingOrder(int childCount, int i) { *

This is used to gate the {@link Canvas#saveLayer} fallback in * {@link #dispatchDraw(Canvas)}: on older HWUI versions, a {@link * Canvas#clipPath} command issued under an ancestor RenderNode that is - * later scaled does not necessarily compose correctly with that scale, - * producing visibly mis-clipped rounded corners. Forcing an offscreen - * layer for the duration of the children draw isolates the clip into a - * pre-rasterized buffer that is then resampled by the ancestor scale, - * which is the same way the ancestor scales every other pixel and - * therefore stays geometrically consistent. + * later scaled does not necessarily compose correctly with that scale. + * Android's hardware acceleration docs state that before API 28, some + * Canvas operations were implemented as scale-1.0 textures and then scaled + * by the GPU; starting from API 28, all drawing operations can scale + * correctly. Forcing an offscreen layer for the duration of the children + * draw isolates the clip into a pre-rasterized buffer that is then + * resampled by the ancestor scale, which is the same way the ancestor + * scales every other pixel and therefore stays geometrically consistent. */ private boolean hasAncestorOrSelfTransform() { - final float epsilon = 1e-4f; View v = this; - // Walk up until we hit a non-View parent (Window root) or null. - while (v != null) { + // Walk up until we hit a non-View parent (Window root), null, or the + // depth guard. A bounded walk keeps dispatchDraw predictable in deeply + // nested list/item trees while still covering the common transform + // owners (self, item container, page container, transition wrapper). + for (int depth = 0; v != null && depth < MAX_TRANSFORM_ANCESTOR_DEPTH; depth++) { float sx = v.getScaleX(); float sy = v.getScaleY(); float rz = v.getRotation(); float rx = v.getRotationX(); float ry = v.getRotationY(); - if (Math.abs(sx - 1f) > epsilon - || Math.abs(sy - 1f) > epsilon - || Math.abs(rz) > epsilon - || Math.abs(rx) > epsilon - || Math.abs(ry) > epsilon) { + if (Math.abs(sx - 1f) > TRANSFORM_EPSILON + || Math.abs(sy - 1f) > TRANSFORM_EPSILON + || Math.abs(rz) > TRANSFORM_EPSILON + || Math.abs(rx) > TRANSFORM_EPSILON + || Math.abs(ry) > TRANSFORM_EPSILON) { return true; } ViewParent p = v.getParent(); @@ -164,8 +170,13 @@ protected void dispatchDraw(Canvas canvas) { // 1. there is actually a rounded-corner clip to apply, AND // 2. this view or one of its ancestors carries a non-trivial scale // or rotation transform. + // This fallback is limited to Android 8.1 (API 27) and below. API 28 + // is the cutoff because Android's hardware acceleration docs state + // that Canvas scaling for all drawing operations works from API 28 + // onward. // In all other cases the legacy clipPath / clipRect path is kept - // verbatim, so non-transformed scenes pay zero extra cost. + // verbatim, so non-transformed scenes and newer Android versions pay + // zero extra cost. // // The layer scope covers exactly the children draw - dispatchDraw is // invoked after onDraw, so the BackgroundDrawable shadow has already @@ -173,6 +184,7 @@ protected void dispatchDraw(Canvas canvas) { // intercepted by the layer. boolean useOffscreenLayer = roundCornerClipPath != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1 && hasAncestorOrSelfTransform(); int restoreCount; if (useOffscreenLayer) { @@ -221,4 +233,4 @@ protected void onDraw(Canvas canvas) { } } } -} +} \ No newline at end of file diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java index 9fd5f15b61b..5e06d50cef1 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java @@ -34,6 +34,7 @@ public class BackgroundDrawable extends BaseDrawable implements BackgroundHolder { private static final String TAG = "BackgroundDrawable"; + private static final float RADIUS_EPSILON = 1e-4f; private final BorderResolvedInfo mResolvedInfo = new BorderResolvedInfo(); private int mBackgroundColor = Color.TRANSPARENT; private int mBorderWidth = 0; @@ -173,16 +174,13 @@ protected void drawBackgroundColor(@NonNull Canvas canvas) { // applied as a matrix transform at draw time and the result remains // crisp at any scale. // - // drawPath, in contrast, has historically been observed on some - // older HWUI versions to render arbitrary Path geometry through a - // path-tessellation cache: the tessellated result may be cached and - // reused, and on those versions an ancestor RenderNode being scaled - // later does not necessarily trigger a re-tessellation at the new - // effective scale, which manifests as blurry / aliased rounded - // corners. The exact mechanism, the affected version range and - // OEM coverage are all HWUI implementation details outside of our - // control, but the observable symptom is reliably absent when the - // same geometry is drawn via drawRoundRect. We therefore prefer + // drawPath, in contrast, falls under the HWUI Canvas-scaling + // limitation documented for complex drawing operations before API + // 28: the hardware-accelerated 2D pipeline may rasterize such + // operations at scale 1.0 and then let the GPU scale that texture, + // which can degrade quality under ancestor transforms. Android's + // hardware acceleration docs list drawPath / complex shapes as + // scaling correctly starting from API 28. We therefore prefer // drawRoundRect whenever the geometry can be expressed as a // uniform-radius round rect. float uniformRadius = mResolvedInfo.getUniformBorderRadius(); @@ -251,23 +249,17 @@ protected void drawBorder(@NonNull Canvas canvas) { // canvas.clipPath(borderOutsidePath); // canvas.clipPath(borderInsidePath, Region.Op.DIFFERENCE); // canvas.drawPath(borderMidlinePath, STROKE); - // to confine the stroke to the border band. On some older HWUI - // versions, non-rectangular clipPath - and especially non-INTERSECT - // clipPath such as Region.Op.DIFFERENCE used here - is not - // accelerated directly on the GPU; instead the affected RenderNode is - // rendered into an intermediate offscreen surface so that the clip - // can be applied via per-pixel masking. Whether the fallback uses an - // HWUI layer, the path tessellation cache or another mechanism, and - // exactly which Android versions / OEM builds are affected, are HWUI - // implementation details outside of our control. What matters here - // is the observable effect: once the RenderNode's content has been - // rasterized at its natural size, an ancestor transform such as - // transform: scale(1.5) can only resample that raster, producing - // visibly blurry / aliased corners and a border that does not appear - // to scale together with its parent. This was originally reported - // on an API 24 device, with an API 31 device rendering correctly; - // those two data points are the only ones we have actually verified - // and should not be read as a precise affected-version range. + // to confine the stroke to the border band. This is a complex Canvas + // operation built from path clipping and drawPath. Android's hardware + // acceleration docs state that before API 28, some drawing operations + // were implemented as scale-1.0 textures and then scaled by the GPU, + // causing quality degradation at high scale; the same docs list + // drawPath / complex shapes as scaling correctly starting from API 28. + // In this path, once the RenderNode's content has been rasterized at + // its natural size, an ancestor transform such as transform: scale(1.5) + // can only resample that raster, producing visibly blurry / aliased + // corners and a border that does not appear to scale together with its + // parent. // // Canvas.drawRoundRect, by contrast, is a first-class HWUI primitive // on every supported API level and is recorded into the display list @@ -335,8 +327,11 @@ protected void drawBorder(@NonNull Canvas canvas) { // may still trigger the offscreen-rasterization behavior described // above. When a rounded complex border cannot use the drawRoundRect // fast path, explicitly isolate the border drawing into a layer on - // older Android versions so the outer and inner path clips are applied - // in the same local rasterization step. + // older Android versions (API 27 and below) so the outer and inner path + // clips are applied in the same local rasterization step. API 28 is the + // cutoff because Android's hardware acceleration docs state that Canvas + // scaling for all drawing operations works from API 28 onward, and list + // drawPath / complex shapes as scaling correctly starting from API 28. boolean useLayer = shouldDrawComplexRoundedBorderInLayer(); if (useLayer) { canvas.saveLayer(mRect, null); @@ -390,6 +385,8 @@ protected void drawBorder(@NonNull Canvas canvas) { } private boolean shouldDrawComplexRoundedBorderInLayer() { + // O_MR1 is API 27, so this fallback applies only before the official + // API 28 Canvas-scaling boundary documented by Android. return mResolvedInfo.hasBorderRadius && Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1; } @@ -406,6 +403,8 @@ && canDrawBorderSideAsFill(mResolvedInfo.strokeWidth.bottom, private boolean canDrawBorderSideAsFill(int strokeWidth, @Nullable PathEffect pathEffect, @Nullable Path fillPath) { + // Only solid borders can be represented by the pre-filled side path; + // dashed / dotted borders must keep the stroked pathEffect flow. return strokeWidth <= 0 || pathEffect == null && fillPath != null; } @@ -630,7 +629,9 @@ public void setBorderRadius(@Px float radius, BorderArc arc) { } public boolean hasSameRadiusOnAllSides() { - return topLeft == topRight && topRight == bottomRight && bottomRight == bottomLeft; + return Math.abs(topLeft - topRight) <= RADIUS_EPSILON + && Math.abs(topRight - bottomRight) <= RADIUS_EPSILON + && Math.abs(bottomRight - bottomLeft) <= RADIUS_EPSILON; } } @@ -721,4 +722,4 @@ public void setBorderStyle(BorderStyle style, BorderSide side) { } } } -} +} \ No newline at end of file From a5d4ae9e151ec1268bf885ff1e3846fcfd1299c9 Mon Sep 17 00:00:00 2001 From: iPel Date: Wed, 17 Jun 2026 14:14:50 +0800 Subject: [PATCH 4/6] fix(android): update scale error on some older devices --- .../renderer/component/FlatViewGroup.java | 146 +++++++----------- 1 file changed, 60 insertions(+), 86 deletions(-) diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java index 5df333fa80b..84414c9d454 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java @@ -19,6 +19,7 @@ import android.content.Context; import android.graphics.Canvas; import android.graphics.Path; +import android.graphics.Rect; import android.os.Build; import android.view.View; import android.view.ViewGroup; @@ -37,7 +38,9 @@ public class FlatViewGroup extends ViewGroup { private static final String TAG = "FlatViewGroup"; private static final float TRANSFORM_EPSILON = 1e-4f; private static final int MAX_TRANSFORM_ANCESTOR_DEPTH = 20; + private static int sOffscreenLayerDepth = 0; private final DispatchDrawHelper mDispatchDrawHelper = new DispatchDrawHelper(); + private final Rect mLayerBounds = new Rect(); public FlatViewGroup(Context context) { super(context); @@ -93,49 +96,14 @@ protected int getChildDrawingOrder(int childCount, int i) { return (index < 0 || index >= getChildCount()) ? i : index; } - /** - * Returns {@code true} if {@code this} view, or any of its ancestors up - * to (but not including) the root view, has a non-identity transform - * applied via the View RenderNode properties - * ({@link View#getScaleX}, {@link View#getScaleY}, - * {@link View#getRotation}, {@link View#getRotationX}, - * {@link View#getRotationY}). - * - *

Why we walk the view tree instead of inspecting the canvas matrix: - * with hardware-accelerated rendering, the canvas passed into - * {@code dispatchDraw} is a per-RenderNode RecordingCanvas whose matrix - * only contains the local translations performed during the current - * RenderNode's display-list recording; the scale/rotation properties of - * an ancestor RenderNode are applied later, on the RenderThread, during - * composition, and never appear in this canvas's matrix. Walking the - * view tree's logical transform properties gives us the truthful - * "is anyone scaling me?" answer regardless of whether software or - * hardware rendering is in effect. - * - *

This is used to gate the {@link Canvas#saveLayer} fallback in - * {@link #dispatchDraw(Canvas)}: on older HWUI versions, a {@link - * Canvas#clipPath} command issued under an ancestor RenderNode that is - * later scaled does not necessarily compose correctly with that scale. - * Android's hardware acceleration docs state that before API 28, some - * Canvas operations were implemented as scale-1.0 textures and then scaled - * by the GPU; starting from API 28, all drawing operations can scale - * correctly. Forcing an offscreen layer for the duration of the children - * draw isolates the clip into a pre-rasterized buffer that is then - * resampled by the ancestor scale, which is the same way the ancestor - * scales every other pixel and therefore stays geometrically consistent. - */ private boolean hasAncestorOrSelfTransform() { - View v = this; - // Walk up until we hit a non-View parent (Window root), null, or the - // depth guard. A bounded walk keeps dispatchDraw predictable in deeply - // nested list/item trees while still covering the common transform - // owners (self, item container, page container, transition wrapper). - for (int depth = 0; v != null && depth < MAX_TRANSFORM_ANCESTOR_DEPTH; depth++) { - float sx = v.getScaleX(); - float sy = v.getScaleY(); - float rz = v.getRotation(); - float rx = v.getRotationX(); - float ry = v.getRotationY(); + View view = this; + for (int depth = 0; view != null && depth < MAX_TRANSFORM_ANCESTOR_DEPTH; depth++) { + float sx = view.getScaleX(); + float sy = view.getScaleY(); + float rz = view.getRotation(); + float rx = view.getRotationX(); + float ry = view.getRotationY(); if (Math.abs(sx - 1f) > TRANSFORM_EPSILON || Math.abs(sy - 1f) > TRANSFORM_EPSILON || Math.abs(rz) > TRANSFORM_EPSILON @@ -143,9 +111,9 @@ private boolean hasAncestorOrSelfTransform() { || Math.abs(ry) > TRANSFORM_EPSILON) { return true; } - ViewParent p = v.getParent(); - if (p instanceof View) { - v = (View) p; + ViewParent parent = view.getParent(); + if (parent instanceof View) { + view = (View) parent; } else { break; } @@ -153,60 +121,67 @@ private boolean hasAncestorOrSelfTransform() { return false; } + private boolean shouldDrawInOffscreenLayer() { + return sOffscreenLayerDepth == 0 + && getWidth() > 0 + && getHeight() > 0 + && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP + && Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1 + && hasAncestorOrSelfTransform(); + } + @Override - protected void dispatchDraw(Canvas canvas) { + public void draw(Canvas canvas) { RenderNode node = RenderManager.getRenderNode(this); if (node == null) { - super.dispatchDraw(canvas); + super.draw(canvas); return; } boolean clipChildren = getClipChildren(); Component component = node.getComponent(); - Path roundCornerClipPath = (clipChildren && component != null) - ? component.getContentRegionPath() : null; - // Decide whether we need to escape into an offscreen layer to - // guarantee that rounded-corner clipping composes correctly with any - // ancestor scale. We do this only when: - // 1. there is actually a rounded-corner clip to apply, AND - // 2. this view or one of its ancestors carries a non-trivial scale - // or rotation transform. - // This fallback is limited to Android 8.1 (API 27) and below. API 28 - // is the cutoff because Android's hardware acceleration docs state - // that Canvas scaling for all drawing operations works from API 28 - // onward. - // In all other cases the legacy clipPath / clipRect path is kept - // verbatim, so non-transformed scenes and newer Android versions pay - // zero extra cost. - // - // The layer scope covers exactly the children draw - dispatchDraw is - // invoked after onDraw, so the BackgroundDrawable shadow has already - // been recorded into the parent RenderNode's display list and is not - // intercepted by the layer. - boolean useOffscreenLayer = roundCornerClipPath != null - && Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP - && Build.VERSION.SDK_INT <= Build.VERSION_CODES.O_MR1 - && hasAncestorOrSelfTransform(); - int restoreCount; + Path roundCornerClipPath = (component != null) ? component.getContentRegionPath() : null; + boolean useOffscreenLayer = shouldDrawInOffscreenLayer(); + int restoreCount = -1; if (useOffscreenLayer) { - // saveLayer with a null paint creates a transparent offscreen - // buffer sized to the current clip; subsequent draws are - // rasterized into that buffer and the buffer is composited back - // at restore time. The clipPath issued *inside* the layer is - // applied while rasterizing, before the ancestor scale resamples - // the buffer, so the resulting rounded-corner mask scales - // together with every other pixel. - restoreCount = canvas.saveLayer(0, 0, getRight() - getLeft(), - getBottom() - getTop(), null); - canvas.clipPath(roundCornerClipPath); - } else { - restoreCount = canvas.save(); + canvas.getClipBounds(mLayerBounds); + restoreCount = canvas.saveLayer(mLayerBounds.left, mLayerBounds.top, + mLayerBounds.right, mLayerBounds.bottom, null); if (clipChildren) { if (roundCornerClipPath != null) { canvas.clipPath(roundCornerClipPath); } else { - canvas.clipRect(0, 0, getRight() - getLeft(), getBottom() - getTop()); + canvas.clipRect(0, 0, getWidth(), getHeight()); } } + } else if (clipChildren) { + restoreCount = canvas.save(); + if (roundCornerClipPath != null) { + canvas.clipPath(roundCornerClipPath); + } else { + canvas.clipRect(0, 0, getWidth(), getHeight()); + } + } + if (useOffscreenLayer) { + sOffscreenLayerDepth++; + } + try { + super.draw(canvas); + } finally { + if (restoreCount >= 0) { + canvas.restoreToCount(restoreCount); + } + if (useOffscreenLayer) { + sOffscreenLayerDepth--; + } + } + } + + @Override + protected void dispatchDraw(Canvas canvas) { + RenderNode node = RenderManager.getRenderNode(this); + if (node == null) { + super.dispatchDraw(canvas); + return; } mDispatchDrawHelper.onDispatchDrawStart(canvas, node); super.dispatchDraw(canvas); @@ -215,7 +190,6 @@ protected void dispatchDraw(Canvas canvas) { mDispatchDrawHelper.drawNext(this); } mDispatchDrawHelper.onDispatchDrawEnd(); - canvas.restoreToCount(restoreCount); } @Override From 17b444fd01c11506d3ed267b598a499ba3363f24 Mon Sep 17 00:00:00 2001 From: iPel Date: Wed, 17 Jun 2026 14:31:50 +0800 Subject: [PATCH 5/6] fix(android): fix review issues --- .../tencent/renderer/component/FlatViewGroup.java | 12 ++++++++++++ .../component/drawable/BackgroundDrawable.java | 5 +++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java index 84414c9d454..14dcb298d3f 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java @@ -38,8 +38,12 @@ public class FlatViewGroup extends ViewGroup { private static final String TAG = "FlatViewGroup"; private static final float TRANSFORM_EPSILON = 1e-4f; private static final int MAX_TRANSFORM_ANCESTOR_DEPTH = 20; + // UI thread only: Android View drawing is expected to be single-threaded, so this + // static counter is only used to avoid nested offscreen layers within one draw pass. private static int sOffscreenLayerDepth = 0; private final DispatchDrawHelper mDispatchDrawHelper = new DispatchDrawHelper(); + // Reused during draw() to avoid allocation. Safe under the same UI-thread-only + // drawing assumption as sOffscreenLayerDepth. private final Rect mLayerBounds = new Rect(); public FlatViewGroup(Context context) { @@ -122,6 +126,9 @@ private boolean hasAncestorOrSelfTransform() { } private boolean shouldDrawInOffscreenLayer() { + // Keep the ancestor transform walk on the old-HWUI fallback path only. Newer + // Android versions handle Canvas scaling for complex drawing operations, so + // they can short-circuit before hasAncestorOrSelfTransform(). return sOffscreenLayerDepth == 0 && getWidth() > 0 && getHeight() > 0 @@ -152,6 +159,11 @@ public void draw(Canvas canvas) { } else { canvas.clipRect(0, 0, getWidth(), getHeight()); } + } else { + // Even without clipping children, the layer is still useful on old + // HWUI when an ancestor transform is present: it isolates this view's + // complex/path drawing into one local rasterization step instead of + // letting nested draws trigger their own scaled texture artifacts. } } else if (clipChildren) { restoreCount = canvas.save(); diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java index 5e06d50cef1..8c8df7af924 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/drawable/BackgroundDrawable.java @@ -34,7 +34,6 @@ public class BackgroundDrawable extends BaseDrawable implements BackgroundHolder { private static final String TAG = "BackgroundDrawable"; - private static final float RADIUS_EPSILON = 1e-4f; private final BorderResolvedInfo mResolvedInfo = new BorderResolvedInfo(); private int mBackgroundColor = Color.TRANSPARENT; private int mBorderWidth = 0; @@ -405,7 +404,7 @@ private boolean canDrawBorderSideAsFill(int strokeWidth, @Nullable PathEffect pa @Nullable Path fillPath) { // Only solid borders can be represented by the pre-filled side path; // dashed / dotted borders must keep the stroked pathEffect flow. - return strokeWidth <= 0 || pathEffect == null && fillPath != null; + return strokeWidth <= 0 || (pathEffect == null && fillPath != null); } private void drawBorderSideFillInternal(@NonNull Canvas canvas, int strokeWidth, int color, @@ -591,6 +590,8 @@ public void setBorderWith(@Px int width, BorderSide side) { public static class BorderRadius { + private static final float RADIUS_EPSILON = 1e-4f; + public float topLeft; public float topRight; public float bottomRight; From 377e6cf31a12ae512310c7e520a3b43eacfb1348 Mon Sep 17 00:00:00 2001 From: iPel Date: Thu, 18 Jun 2026 19:39:25 +0800 Subject: [PATCH 6/6] fix(android): defer flat view clipping to dispatch draw --- .../renderer/component/FlatViewGroup.java | 50 +++++++++---------- 1 file changed, 23 insertions(+), 27 deletions(-) diff --git a/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java b/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java index 14dcb298d3f..53496ff4288 100644 --- a/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java +++ b/renderer/native/android/src/main/java/com/tencent/renderer/component/FlatViewGroup.java @@ -144,34 +144,12 @@ public void draw(Canvas canvas) { super.draw(canvas); return; } - boolean clipChildren = getClipChildren(); - Component component = node.getComponent(); - Path roundCornerClipPath = (component != null) ? component.getContentRegionPath() : null; boolean useOffscreenLayer = shouldDrawInOffscreenLayer(); int restoreCount = -1; if (useOffscreenLayer) { canvas.getClipBounds(mLayerBounds); restoreCount = canvas.saveLayer(mLayerBounds.left, mLayerBounds.top, mLayerBounds.right, mLayerBounds.bottom, null); - if (clipChildren) { - if (roundCornerClipPath != null) { - canvas.clipPath(roundCornerClipPath); - } else { - canvas.clipRect(0, 0, getWidth(), getHeight()); - } - } else { - // Even without clipping children, the layer is still useful on old - // HWUI when an ancestor transform is present: it isolates this view's - // complex/path drawing into one local rasterization step instead of - // letting nested draws trigger their own scaled texture artifacts. - } - } else if (clipChildren) { - restoreCount = canvas.save(); - if (roundCornerClipPath != null) { - canvas.clipPath(roundCornerClipPath); - } else { - canvas.clipRect(0, 0, getWidth(), getHeight()); - } } if (useOffscreenLayer) { sOffscreenLayerDepth++; @@ -195,13 +173,31 @@ protected void dispatchDraw(Canvas canvas) { super.dispatchDraw(canvas); return; } + boolean clipChildren = getClipChildren(); + int restoreCount = -1; + if (clipChildren) { + Component component = node.getComponent(); + Path roundCornerClipPath = (component != null) ? component.getContentRegionPath() : null; + restoreCount = canvas.save(); + if (roundCornerClipPath != null) { + canvas.clipPath(roundCornerClipPath); + } else { + canvas.clipRect(0, 0, getWidth(), getHeight()); + } + } mDispatchDrawHelper.onDispatchDrawStart(canvas, node); - super.dispatchDraw(canvas); - if (mDispatchDrawHelper.isActive()) { - // Check the remaining non rendered sub nodes, behind the last sub node with host view - mDispatchDrawHelper.drawNext(this); + try { + super.dispatchDraw(canvas); + if (mDispatchDrawHelper.isActive()) { + // Check the remaining non rendered sub nodes, behind the last sub node with host view + mDispatchDrawHelper.drawNext(this); + } + } finally { + mDispatchDrawHelper.onDispatchDrawEnd(); + if (restoreCount >= 0) { + canvas.restoreToCount(restoreCount); + } } - mDispatchDrawHelper.onDispatchDrawEnd(); } @Override