Skip to content

feat(android): improve borderRadius, resorting and reflection code#14451

Open
m1ga wants to merge 1 commit into
mainfrom
androidImprovements
Open

feat(android): improve borderRadius, resorting and reflection code#14451
m1ga wants to merge 1 commit into
mainfrom
androidImprovements

Conversation

@m1ga
Copy link
Copy Markdown
Contributor

@m1ga m1ga commented May 23, 2026

Changes

1. Reuse Path objects in TiBorderWrapperView (TiBorderWrapperView.java:44-45, 95-96)

  • Pre-allocated outerPath and innerPath as instance fields
  • In onDraw(), calls path.rewind() instead of new Path() each frame
  • Eliminates per-frame GC churn from path allocation

2. Border without wrapper via ViewOutlineProvider (TiUIView.java:37,65,476-520,913-917,1565-1574,1639-1645)

  • When only borderRadius is set (no border color/width), uses ViewOutlineProvider + setClipToOutline(true) on the native view directly
  • Avoids the costly hierarchy mutation (remove from parent, wrap, re-add) that TiBorderWrapperView required
  • Falls back to the wrapper when border stroke properties are present
  • Only applies on API 21+ (all modern Android)

3. Only re-sort on zIndex change (TiCompositeLayout.java:369-375)

  • resort() now returns early if needsSort is already true, preventing redundant requestLayout() calls
  • Removed invalidate() from resort() since requestLayout() already schedules a draw pass
  • Works with existing onChildViewAdded/onChildViewRemoved listeners that also set needsSort

4. Eliminate reflection in TiBackgroundColorWrapper (TiBackgroundColorWrapper.java - entire file)

  • Removed all unused reflection code: cdBackgroundStateField, cdBackgroundStateColorField, cdBackgroundReflectionReady, initColorDrawableReflection(), Field import
  • ColorDrawable.getColor() has been available since API 11 and already handled correctly in getBackgroundColor() — the reflection code was dead code

5. Batch invalidate-only properties (TiUIView.java:665)

  • layoutNativeView() now uses bLayoutPending.compareAndSet(false, true) to skip redundant setup when a layout pass is already pending
  • Prevents setting up duplicate OnGlobalLayoutListener instances and redundant requestLayout() calls when multiple layout properties change in sequence (e.g., setting left, top, width, height at once via applyProperties())

Test code

Test borderRadius and toImage()
const win = Ti.UI.createWindow({ backgroundColor:"#ddd" });
const view = Ti.UI.createView({width: 50, height: 50, borderRadius: 25, left: 10, backgroundColor:"red"});
const img = Ti.UI.createImageView({ image:"/image.jpg", backgroundColor:"#999", borderRadius: 25, height: 50, width: 50, right: 10});
const img_check1 = Ti.UI.createImageView({ width: 50, height: 50, bottom: 10, left: 10})
const img_check2 = Ti.UI.createImageView({ width: 50, height: 50, bottom: 10, right: 10})

win.add([img,view, img_check1, img_check2]);

win.addEventListener("open", function() {
	setTimeout(function() {
		img_check1.image = view.toImage();
		img_check2.image = img.toImage();
	}, 1000)
})

win.open();
Performance benchmark
function formatNum(n)
{
	return String(Math.round(n)).replace(/\B(?=(\d{3})+(?!\d))/g, ',');
}

function benchBorders(win, finish)
{
	var results = [];
  var ITERATIONS = 1000;

	var outlineView = Ti.UI.createView({
		width: 50, height: 50, top: 10, left: 10,
		backgroundColor: 'red',
		borderRadius: 10
	});
	win.add(outlineView);

	var borderView = Ti.UI.createView({
		width: 50, height: 50, top: 10, left: 70,
		backgroundColor: 'blue',
		borderColor: 'black', borderWidth: 3, borderRadius: 10
	});
	win.add(borderView);

	var plainView = Ti.UI.createView({
		width: 50, height: 50, top: 10, left: 130,
		backgroundColor: 'green'
	});
	win.add(plainView);

	// Wait for layout.
	setTimeout(function () {
		var start, elapsed, i;

		// --- borderView backgroundColor (triggers redraw with path reuse) ---
		start = Date.now();
		for (i = 0; i < ITERATIONS; i++) {
			borderView.backgroundColor = (i % 2 === 0) ? 'blue' : 'lightblue';
		}
		elapsed = Date.now() - start;
		var borderColorMs = elapsed;
		results.push('borderColorChange(' + ITERATIONS + 'x): ' + elapsed + 'ms');

		// --- outlineView borderRadius (uses ViewOutlineProvider) ---
		start = Date.now();
		for (i = 0; i < ITERATIONS; i++) {
			outlineView.borderRadius = (i % 20) + 5;
		}
		elapsed = Date.now() - start;
		var outlineRadiusMs = elapsed;
		results.push('outlineRadiusChange(' + ITERATIONS + 'x): ' + elapsed + 'ms');

		// --- plainView backgroundColor (baseline) ---
		start = Date.now();
		for (i = 0; i < ITERATIONS; i++) {
			plainView.backgroundColor = (i % 2 === 0) ? 'green' : 'lightgreen';
		}
		elapsed = Date.now() - start;
		var plainColorMs = elapsed;
		results.push('plainColorChange(' + ITERATIONS + 'x): ' + elapsed + 'ms');

		results.forEach(function (r) { Ti.API.info(r); });
		finish(null, 'Border', [
			{ label: 'borderColorChange', ms: borderColorMs },
			{ label: 'outlineRadiusChange', ms: outlineRadiusMs },
			{ label: 'plainColorChange (baseline)', ms: plainColorMs }
		]);
	}, 300);
}

function benchZIndex(win, finish)
{
	var COUNT = 20;
  var ITERATIONS = 100;
	var views = [];
	var colors = [
		'red', 'blue', 'green', 'orange', 'purple', 'cyan',
		'magenta', 'yellow', 'brown', 'pink'
	];

	// Lay out in a diagonal stack.
	for (var i = 0; i < COUNT; i++) {
		var v = Ti.UI.createView({
			width: 80, height: 80,
			left: i * 8 + 10,
			top: i * 8 + 60,
			backgroundColor: colors[i % colors.length],
			opacity: 0.85,
			zIndex: i
		});
		win.add(v);
		views.push(v);
	}

	setTimeout(function () {
		var start = Date.now();
		for (var j = 0; j < ITERATIONS; j++) {
			for (var k = 0; k < COUNT; k++) {
				views[k].zIndex = (k + j) % COUNT;
			}
		}
		var elapsed = Date.now() - start;
		Ti.API.info('zIndexResort(' + (ITERATIONS * COUNT) + ' changes): ' + elapsed + 'ms');
		finish(null, 'Z-Index', [
			{ label: 'zIndexChanges(' + (ITERATIONS * COUNT) + ')', ms: elapsed }
		]);
	}, 300);
}

function benchBatchLayout(win, finish)
{
  var ITERATIONS = 100;

	var v = Ti.UI.createView({
		width: 80, height: 80, top: 60, left: 10,
		backgroundColor: 'purple'
	});
	win.add(v);

	setTimeout(function () {
		// Individual setters.
		var start = Date.now();
		for (var i = 0; i < ITERATIONS; i++) {
			v.left = 10 + i;
			v.top = 60 + i;
			v.width = 80 + (i % 20);
			v.height = 80 + (i % 20);
		}
		var individualMs = Date.now() - start;

		// Batched via applyProperties.
		start = Date.now();
		for (i = 0; i < ITERATIONS; i++) {
			v.applyProperties({
				left: 10 + i,
				top: 60 + i,
				width: 80 + (i % 20),
				height: 80 + (i % 20)
			});
		}
		var batchedMs = Date.now() - start;

		Ti.API.info('individualSetters(' + ITERATIONS + 'x4): ' + individualMs + 'ms');
		Ti.API.info('applyProperties(' + ITERATIONS + 'x4): ' + batchedMs + 'ms');
		Ti.API.info('ratio: ' + (individualMs / Math.max(batchedMs, 1)).toFixed(2) + 'x');

		finish(null, 'Batch Layout', [
			{ label: 'individualSetters(' + ITERATIONS + 'x4)', ms: individualMs },
			{ label: 'applyProperties(' + ITERATIONS + 'x4)', ms: batchedMs },
			{ label: 'ratio (individual / batched)', ms: null, ratio: (individualMs / Math.max(batchedMs, 1)).toFixed(2) }
		]);
	}, 300);
}


function benchViewCreation(win, finish)
{
	// Create many views with borderRadius only (no border stroke).
	// After optimization: uses ViewOutlineProvider, no wrapper/hierarchy mutation.
  var COUNT = 400;

	setTimeout(function () {
		var start = Date.now();
		for (var i = 0; i < COUNT; i++) {
			var v = Ti.UI.createView({
				width: 28, height: 28,
				left: (i % 20) * 32 + 10,
				top: Math.floor(i / 20) * 32 + 60,
				backgroundColor: 'orange',
				borderRadius: 14
			});
			win.add(v);
		}
		var elapsed = Date.now() - start;
		Ti.API.info('createViewsWithBorderRadius(' + COUNT + 'x): ' + elapsed + 'ms');
		finish(null, 'View Creation', [
			{ label: 'createViewsWithBorderRadius(' + COUNT + 'x)', ms: elapsed }
		]);
	}, 300);
}


(function () {

	// ── App state ────────────────────────────────────────────
	var results = [];          // { suite, metrics: [{label, ms, ratio?}] }
	var pending = 0;
	var benchWindow;

	// ── Helpers ──────────────────────────────────────────────
	function benchComplete(err, suiteName, metrics)
	{
		pending--;
		if (err) {
			Ti.API.error(suiteName + ' failed: ' + err);
		} else {
			results.push({ suite: suiteName, metrics: metrics });
		}
		if (pending === 0) {
			renderResults();
		}
	}

	// ── Results scroll view ──────────────────────────────────
	function renderResults()
	{
		var scroll = Ti.UI.createScrollView({
			top: 0, left: 0, right: 0, bottom: 0,
			backgroundColor: '#1a1a2e',
			contentHeight: Ti.UI.SIZE
		});

		var y = 10;

		// Title
		scroll.add(Ti.UI.createLabel({
			top: y, left: 16, right: 16,
			text: 'Render Engine Benchmarks',
			color: '#e94560',
			font: { fontSize: 20, fontWeight: 'bold' },
			textAlign: Ti.UI.TEXT_ALIGNMENT_CENTER,
			height: Ti.UI.SIZE
		}));
		y += 40;

		// Results per suite.
		for (var s = 0; s < results.length; s++) {
			var suite = results[s];

			scroll.add(Ti.UI.createLabel({
				top: y, left: 16, right: 16,
				text: suite.suite,
				color: '#0f3460',
				backgroundColor: '#e94560',
				font: { fontSize: 15, fontWeight: 'bold' },
				padding: { left: 8, top: 4, bottom: 4 },
				height: Ti.UI.SIZE
			}));
			y += 28;

			for (var m = 0; m < suite.metrics.length; m++) {
				var met = suite.metrics[m];
				var line = '  ' + met.label + ': ';
				if (met.ms !== null) {
					line += formatNum(met.ms) + ' ms';
				}
				if (met.ratio) {
					line += '  (' + met.ratio + 'x)';
				}

				scroll.add(Ti.UI.createLabel({
					top: y, left: 16, right: 16,
					text: line,
					color: '#eee',
					font: { fontSize: 13, fontFamily: 'monospace' },
					height: Ti.UI.SIZE
				}));
				y += 22;
			}

			y += 8;
		}

		// Note
		scroll.add(Ti.UI.createLabel({
			top: y, left: 16, right: 16, bottom: 20,
			text: 'Lower is better. Results logged via Ti.API.info.',
			color: '#888',
			font: { fontSize: 11 },
			textAlign: Ti.UI.TEXT_ALIGNMENT_CENTER,
			height: Ti.UI.SIZE
		}));

		benchWindow.removeAllChildren();
		benchWindow.add(scroll);
	}

	// ── Launch ───────────────────────────────────────────────
	benchWindow = Ti.UI.createWindow({
		backgroundColor: '#1a1a2e',
		orientationModes: [ Ti.UI.PORTRAIT ]
	});

	var statusLabel = Ti.UI.createLabel({
		top: 100, left: 20, right: 20,
		text: 'Running benchmarks...',
		color: '#eee',
		font: { fontSize: 16 },
		textAlign: Ti.UI.TEXT_ALIGNMENT_CENTER,
		height: Ti.UI.SIZE
	});
	benchWindow.add(statusLabel);

	benchWindow.open();

	// Run all 4 benchmarks, each in an isolated child view.
	pending = 4;

	var runBench = function (benchFn, name) {
		var container = Ti.UI.createView({
			top: -2000, left: -2000,  // offscreen to not flicker
			width: 10, height: 10,
			backgroundColor: 'transparent'
		});
		benchWindow.add(container);

		benchFn(container, function (err, suiteName, metrics) {
			benchWindow.remove(container);
			benchComplete(err, suiteName, metrics);
		});
	};

	runBench(benchBorders);
	runBench(benchZIndex);
	runBench(benchBatchLayout);
	runBench(benchViewCreation);
})();

Results

13.2.0:
Screenshot_20260523-200732

13.3.0:
Screenshot_20260523-200815
(border parts are a little slower but produce less UI so the it should benefit scrolling)

TiBorderWrapper is gone now and a border on a image will just generate:
Bildschirmfoto_20260523_201641

Sorry had to blur usernames but in a listview it currently looks like this (13.2.0):
Bildschirmfoto_20260523_211409
each round image is producing a borderWrapperView + an imageview

with 13.3.0 it will just be a single image, no nested element for each of the rows:
Bildschirmfoto_20260523_211259

Custom borderRadius with an array still works fine too:
Screenshot_20260523-213133

Fixes #13464

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Android: Optimize views with borderRadius

1 participant