fix(mobile perf): avoid eager emoji palette scans for prose (#8416)

Ordinary mobile messages eagerly built a set of every community emoji
shortcode and segmented their entire body just to decide whether to use
enlarged emoji rendering. Stop at the first non-emoji grapheme and build
the shortcode set only when a complete shortcode is encountered.
Emoji-only sizing and shortcode matching stay unchanged.

### Validation

- Full `just ci` passed.
- 125 focused detector and message-rendering tests pass; targeted
analysis is clean. The palette-access regression fails on the original
implementation (10,320 unnecessary reads) and passes with the fix. It
also checks that consecutive shortcodes share one palette scan.
- **Full Buzz app, real community data, debug/JIT iOS simulator, not a
rendering fixture:** two paired actual-input captures measured 125–127
µs per original detector call versus under 1 µs with the fix. All 5,200
executions per version agreed across 260 observed prose calls and a
2,580-entry palette. Instrumentation repeated each detector call, so
these captures are excluded from frame comparisons.
- Separate clean-code scrolling runs do **not** establish a reliable
smoothness improvement: quiet original UI-callback p95 was 10.1–12.6 ms
versus 10.1–12.9 ms fixed. A busy fixed run still reached 90.8 ms.
Normal app services stayed enabled; only scrolling was exercised. Live
background refresh, debug mode, host scheduling, and profiler overhead
limit interpretation. No physical-device/release-AOT or end-to-end
interaction-latency claim.

Signed-off-by: Tom Brow <tomb@block.xyz>
This commit is contained in:
Tom Brow
2026-10-17 16:53:48 -07:00
committed by GitHub
parent 6286a54321
commit a96ace4e55
2 changed files with 72 additions and 20 deletions

View File

@@ -44,35 +44,35 @@ bool isEmojiOnlyMessage(
final trimmed = content.trim(); final trimmed = content.trim();
if (trimmed.isEmpty) return false; if (trimmed.isEmpty) return false;
final shortcodes = { Set<String>? shortcodes;
for (final emoji in customEmoji) emoji.shortcode.toLowerCase(),
};
var sawEmoji = false; var sawEmoji = false;
// Grapheme clusters, so a ZWJ family or a flag pair is one unit. Desktop // Grapheme clusters, so a ZWJ family or a flag pair is one unit. Desktop
// hand-rolls this scan; Dart's `characters` already segments correctly. // hand-rolls this scan; Dart's `characters` already segments correctly.
final iterator = trimmed.characters.iterator; final iterator = trimmed.characters.iterator;
final clusters = <String>[];
while (iterator.moveNext()) { while (iterator.moveNext()) {
clusters.add(iterator.current); final cluster = iterator.current;
}
var index = 0; if (cluster.trim().isEmpty) continue;
while (index < clusters.length) {
final cluster = clusters[index];
if (cluster.trim().isEmpty) {
index += 1;
continue;
}
if (cluster == ':') { if (cluster == ':') {
final end = clusters.indexOf(':', index + 1); final shortcode = StringBuffer();
if (end <= index + 1) return false; var closed = false;
final shortcode = clusters.sublist(index + 1, end).join().toLowerCase(); while (iterator.moveNext()) {
if (!shortcodes.contains(shortcode)) return false; if (iterator.current == ':') {
closed = true;
break;
}
shortcode.write(iterator.current);
}
if (!closed || shortcode.isEmpty) return false;
shortcodes ??= {
for (final emoji in customEmoji) emoji.shortcode.toLowerCase(),
};
if (!shortcodes.contains(shortcode.toString().toLowerCase())) {
return false;
}
sawEmoji = true; sawEmoji = true;
index = end + 1;
continue; continue;
} }
@@ -80,7 +80,6 @@ bool isEmojiOnlyMessage(
return false; return false;
} }
sawEmoji = true; sawEmoji = true;
index += 1;
} }
return sawEmoji; return sawEmoji;

View File

@@ -1,3 +1,5 @@
import 'dart:collection';
import 'package:buzz/shared/custom_emoji/custom_emoji.dart'; import 'package:buzz/shared/custom_emoji/custom_emoji.dart';
import 'package:buzz/shared/emoji/emoji_only.dart'; import 'package:buzz/shared/emoji/emoji_only.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -55,6 +57,34 @@ void main() {
expect(emojiOnly('hi :buzz:', customEmoji: _customEmoji), isFalse); expect(emojiOnly('hi :buzz:', customEmoji: _customEmoji), isFalse);
}); });
test('scans consecutive shortcodes without skipping following text', () {
expect(emojiOnly(':BUZZ::buzz:', customEmoji: _customEmoji), isTrue);
expect(emojiOnly('😀:buzz:😀', customEmoji: _customEmoji), isTrue);
expect(
emojiOnly(':buzz:ordinary text', customEmoji: _customEmoji),
isFalse,
);
expect(
emojiOnly(':buzz::unfinished', customEmoji: _customEmoji),
isFalse,
);
expect(emojiOnly(':buzz:::', customEmoji: _customEmoji), isFalse);
});
test('does not inspect the community palette without a shortcode', () {
final palette = _CountingEmojiList();
expect(
emojiOnly('Ordinary message text. ' * 500, customEmoji: palette),
isFalse,
);
expect(emojiOnly('😀 👋', customEmoji: palette), isTrue);
expect(emojiOnly('text :buzz:', customEmoji: palette), isFalse);
expect(emojiOnly(':unfinished', customEmoji: palette), isFalse);
expect(palette.reads, 0);
expect(emojiOnly(':buzz::BUZZ:', customEmoji: palette), isTrue);
expect(palette.reads, palette.length);
});
test('an unknown shortcode is just text', () { test('an unknown shortcode is just text', () {
// Desktop behaves the same: without the palette it renders literally, so // Desktop behaves the same: without the palette it renders literally, so
// it must not be scaled up as if it were a glyph. // it must not be scaled up as if it were a glyph.
@@ -79,3 +109,26 @@ void main() {
}); });
}); });
} }
class _CountingEmojiList extends ListBase<CustomEmoji> {
int reads = 0;
@override
int get length => 2580;
@override
set length(int value) => throw UnsupportedError('read-only fixture');
@override
CustomEmoji operator [](int index) {
reads += 1;
return CustomEmoji(
shortcode: index == 0 ? 'buzz' : 'unused_$index',
url: 'https://example.test/emoji.png',
);
}
@override
void operator []=(int index, CustomEmoji value) =>
throw UnsupportedError('read-only fixture');
}