feat: переделал lottie -> rlottie, с первым прогревом кадров в фоне, как в тг, чтоб лагов не было

This commit is contained in:
Jganenokk
2026-07-11 00:02:48 +07:00
parent aec536666f
commit 8ab74739b5
34 changed files with 1285 additions and 182 deletions
+2
View File
@@ -0,0 +1,2 @@
export 'rlottie_engine_stub.dart'
if (dart.library.io) 'rlottie_engine.dart';
@@ -0,0 +1,184 @@
import 'dart:async';
import 'dart:io';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
import 'package:path_provider/path_provider.dart';
import '../../utils/logger.dart';
class DiskClip {
DiskClip({
required this.px,
required this.frameCount,
required this.frameRate,
required this.durationMs,
required this.frames,
});
final int px;
final int frameCount;
final double frameRate;
final int durationMs;
final List<Uint8List> frames;
}
class RlottieDiskCache {
RlottieDiskCache._();
static final RlottieDiskCache instance = RlottieDiskCache._();
static const _magic = 0x4b524c46;
static const _version = 1;
static const int _maxBytes = 256 * 1024 * 1024;
Directory? _dir;
Future<Directory>? _dirFuture;
Future<Directory> _directory() {
return _dirFuture ??= () async {
final base = await getApplicationSupportDirectory();
final dir = Directory('${base.path}/rlottie_frames');
if (!await dir.exists()) await dir.create(recursive: true);
_dir = dir;
return dir;
}();
}
String _key(String url, int px) {
final digest = sha1.convert(url.codeUnits).toString().substring(0, 20);
return '${digest}_$px.krlf';
}
Future<File> _file(String url, int px) async {
final dir = await _directory();
return File('${dir.path}/${_key(url, px)}');
}
Future<DiskClip?> load(String url, int px) async {
try {
final file = await _file(url, px);
if (!await file.exists()) return null;
final bytes = await file.readAsBytes();
final clip = await _decode(bytes);
if (clip != null) {
unawaited(file.setLastModified(DateTime.now()).catchError((_) {}));
}
return clip;
} catch (e) {
logger.w('RlottieDiskCache.load failed: $e');
return null;
}
}
Future<void> store({
required String url,
required int px,
required int frameCount,
required double frameRate,
required int durationMs,
required List<Uint8List> frames,
}) async {
if (frames.isEmpty || frames.length != frameCount) return;
try {
final bytes = await _encode(px, frameCount, frameRate, durationMs, frames);
final file = await _file(url, px);
await file.writeAsBytes(bytes, flush: false);
unawaited(_evict());
} catch (e) {
logger.w('RlottieDiskCache.store failed: $e');
}
}
static Future<Uint8List> _encode(
int px,
int frameCount,
double frameRate,
int durationMs,
List<Uint8List> frames,
) {
return Isolate.run(() {
final frameBytes = px * px * 4;
final payload = Uint8List(frameBytes * frameCount);
for (var i = 0; i < frameCount; i++) {
payload.setRange(i * frameBytes, (i + 1) * frameBytes, frames[i]);
}
final compressed = gzip.encode(payload);
final header = ByteData(28);
header.setUint32(0, _magic);
header.setUint8(4, _version);
header.setUint32(8, px);
header.setUint32(12, frameCount);
header.setFloat64(16, frameRate);
header.setUint32(24, durationMs);
final out = BytesBuilder();
out.add(header.buffer.asUint8List());
out.add(compressed);
return out.toBytes();
});
}
static Future<DiskClip?> _decode(Uint8List bytes) {
return Isolate.run(() {
if (bytes.length < 28) return null;
final header = ByteData.sublistView(bytes, 0, 28);
if (header.getUint32(0) != _magic) return null;
if (header.getUint8(4) != _version) return null;
final px = header.getUint32(8);
final frameCount = header.getUint32(12);
final frameRate = header.getFloat64(16);
final durationMs = header.getUint32(24);
final frameBytes = px * px * 4;
final payload = Uint8List.fromList(gzip.decode(bytes.sublist(28)));
if (payload.length != frameBytes * frameCount) return null;
final frames = <Uint8List>[];
for (var i = 0; i < frameCount; i++) {
frames.add(Uint8List.sublistView(
payload, i * frameBytes, (i + 1) * frameBytes));
}
return DiskClip(
px: px,
frameCount: frameCount,
frameRate: frameRate,
durationMs: durationMs,
frames: frames,
);
});
}
Future<void> _evict() async {
try {
final dir = _dir ?? await _directory();
final files = await dir
.list()
.where((e) => e is File && e.path.endsWith('.krlf'))
.cast<File>()
.toList();
var total = 0;
final stats = <(File, FileStat)>[];
for (final f in files) {
final st = await f.stat();
total += st.size;
stats.add((f, st));
}
if (total <= _maxBytes) return;
stats.sort((a, b) => a.$2.modified.compareTo(b.$2.modified));
for (final (file, st) in stats) {
if (total <= _maxBytes) break;
total -= st.size;
await file.delete().catchError((_) => file);
}
} catch (e) {
logger.w('RlottieDiskCache.evict failed: $e');
}
}
Future<void> clear() async {
try {
final dir = await _directory();
if (await dir.exists()) await dir.delete(recursive: true);
_dir = null;
_dirFuture = null;
} catch (_) {}
}
}
+314
View File
@@ -0,0 +1,314 @@
import 'dart:async';
import 'dart:isolate';
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import '../../utils/logger.dart';
import 'rlottie_disk_cache.dart';
import 'rlottie_ffi.dart';
import 'rlottie_worker.dart';
class RlottieClip {
RlottieClip({required this.key, required this.px});
final String key;
final int px;
int frameCount = 0;
int durationMs = 1000;
double frameRate = 60;
List<ui.Image?> _images = const [];
ui.Image? _lastImage;
final ValueNotifier<int> ready = ValueNotifier(0);
bool complete = false;
int bytes = 0;
int lastUsed = 0;
int active = 0;
bool get playable => frameCount > 0;
ui.Image? frameAt(int index) {
if (index < 0 || index >= _images.length) return _lastImage;
return _images[index] ?? _lastImage;
}
void _allocate(int count) {
_images = List<ui.Image?>.filled(count, null);
}
void _setFrame(int index, ui.Image image) {
if (index < 0 || index >= _images.length) {
image.dispose();
return;
}
_images[index] = image;
_lastImage = image;
bytes += px * px * 4;
var r = ready.value;
while (r < _images.length && _images[r] != null) {
r++;
}
if (r != ready.value) ready.value = r;
}
void dispose() {
for (final img in _images) {
img?.dispose();
}
_images = const [];
_lastImage = null;
bytes = 0;
ready.dispose();
}
}
class _Job {
_Job(this.clip, this.url, this.completer);
final RlottieClip clip;
final String url;
final Completer<RlottieClip?> completer;
List<Uint8List?> rawFrames = const [];
}
class RlottieEngine {
RlottieEngine._();
static final RlottieEngine instance = RlottieEngine._();
static String? debugLibraryPath;
static const int _maxBytes = 384 * 1024 * 1024;
static const int _modelCacheBytes = 20 * 1024 * 1024;
final Map<String, RlottieClip> _clips = {};
final Map<String, Future<RlottieClip?>> _loading = {};
final Map<int, _Job> _jobs = {};
int _totalBytes = 0;
int _clock = 0;
int _nextJobId = 1;
bool? _available;
Future<SendPort>? _workerFuture;
int _tick() => ++_clock;
String _keyFor(String url, int px) => '$url@$px';
bool get available {
return _available ??= () {
final bindings = RlottieBindings.open(path: debugLibraryPath);
if (bindings == null) return false;
bindings.configureModelCache(_modelCacheBytes);
return true;
}();
}
Future<SendPort> _worker() {
return _workerFuture ??= () async {
final receive = ReceivePort();
await Isolate.spawn(rlottieWorkerMain, receive.sendPort);
final broadcast = receive.asBroadcastStream();
final port = await broadcast.first as SendPort;
broadcast.listen(_onWorkerMessage);
return port;
}();
}
void _onWorkerMessage(dynamic message) {
if (message is ClipMeta) {
_onMeta(message);
} else if (message is RenderedFrame) {
_onFrame(message);
} else if (message is RenderDone) {
_onDone(message);
} else if (message is RenderError) {
_onError(message);
}
}
void _onMeta(ClipMeta meta) {
final job = _jobs[meta.jobId];
if (job == null) return;
final clip = job.clip
..frameCount = meta.totalFrame
..frameRate = meta.frameRate
..durationMs = meta.durationMs;
clip._allocate(meta.totalFrame);
job.rawFrames = List<Uint8List?>.filled(meta.totalFrame, null);
if (!job.completer.isCompleted) {
job.completer.complete(clip);
}
}
void _onFrame(RenderedFrame frame) {
final job = _jobs[frame.jobId];
if (job == null) return;
final bytes = frame.data.materialize().asUint8List();
if (frame.index < job.rawFrames.length) {
job.rawFrames[frame.index] = bytes;
}
ui.decodeImageFromPixels(
bytes,
frame.px,
frame.px,
ui.PixelFormat.rgba8888,
(image) {
job.clip._setFrame(frame.index, image);
_totalBytes += frame.px * frame.px * 4;
_evictIfNeeded();
},
);
}
void _onDone(RenderDone done) {
final job = _jobs.remove(done.jobId);
if (job == null) return;
final clip = job.clip..complete = true;
final raw = job.rawFrames;
if (raw.length == clip.frameCount && !raw.contains(null)) {
unawaited(RlottieDiskCache.instance.store(
url: job.url,
px: clip.px,
frameCount: clip.frameCount,
frameRate: clip.frameRate,
durationMs: clip.durationMs,
frames: raw.cast<Uint8List>(),
));
}
job.rawFrames = const [];
}
void _onError(RenderError error) {
final job = _jobs.remove(error.jobId);
if (job == null) return;
logger.w('rlottie render failed (${job.url}): ${error.message}');
if (!job.completer.isCompleted) job.completer.complete(null);
if (job.clip.frameCount == 0) {
_clips.remove(job.clip.key);
job.clip.dispose();
}
}
Future<RlottieClip?> acquire(String url, int px, {String? inlineJson}) async {
if (!available) return null;
final key = _keyFor(url, px);
final cached = _clips[key];
if (cached != null) {
cached.lastUsed = _tick();
cached.active++;
return cached;
}
final pending = _loading[key];
if (pending != null) {
final clip = await pending;
if (clip != null) {
clip.lastUsed = _tick();
clip.active++;
}
return clip;
}
final future = _load(url, px, key, inlineJson);
_loading[key] = future;
final clip = await future;
_loading.remove(key);
if (clip != null) {
clip.lastUsed = _tick();
clip.active++;
}
return clip;
}
Future<RlottieClip?> _load(
String url, int px, String key, String? inlineJson) async {
if (inlineJson == null) {
final disk = await RlottieDiskCache.instance.load(url, px);
if (disk != null) {
final clip = await _clipFromDisk(key, px, disk);
_clips[key] = clip;
return clip;
}
}
final json = inlineJson ?? await _fetchJson(url);
if (json == null) return null;
final clip = RlottieClip(key: key, px: px);
_clips[key] = clip;
final jobId = _nextJobId++;
final completer = Completer<RlottieClip?>();
_jobs[jobId] = _Job(clip, url, completer);
final port = await _worker();
port.send(RenderJob(
jobId: jobId,
json: json,
cacheKey: url,
px: px,
libPath: debugLibraryPath,
));
return completer.future;
}
Future<RlottieClip> _clipFromDisk(String key, int px, DiskClip disk) async {
final clip = RlottieClip(key: key, px: px)
..frameCount = disk.frameCount
..frameRate = disk.frameRate
..durationMs = disk.durationMs
..complete = true;
clip._allocate(disk.frameCount);
for (var i = 0; i < disk.frameCount; i++) {
final image = await _decode(disk.frames[i], px);
clip._setFrame(i, image);
_totalBytes += px * px * 4;
}
_evictIfNeeded();
return clip;
}
Future<ui.Image> _decode(Uint8List rgba, int px) {
final completer = Completer<ui.Image>();
ui.decodeImageFromPixels(
rgba, px, px, ui.PixelFormat.rgba8888, completer.complete);
return completer.future;
}
Future<String?> _fetchJson(String url) async {
try {
final file = await DefaultCacheManager().getSingleFile(url);
return await file.readAsString();
} catch (e) {
logger.w('rlottie fetch failed ($url): $e');
return null;
}
}
Future<void> prewarm(String url, int px) async {
if (!available) return;
final clip = await acquire(url, px);
if (clip != null) release(clip);
}
void release(RlottieClip clip) {
if (clip.active > 0) clip.active--;
clip.lastUsed = _tick();
_evictIfNeeded();
}
void _evictIfNeeded() {
if (_totalBytes <= _maxBytes) return;
final candidates = _clips.values.where((c) => c.active <= 0).toList()
..sort((a, b) => a.lastUsed.compareTo(b.lastUsed));
for (final clip in candidates) {
if (_totalBytes <= _maxBytes) break;
_totalBytes -= clip.bytes;
_clips.remove(clip.key);
clip.dispose();
}
}
}
@@ -0,0 +1,31 @@
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
class RlottieClip {
RlottieClip({this.px = 0});
final int px;
int frameCount = 0;
int durationMs = 1000;
double frameRate = 60;
final ValueNotifier<int> ready = ValueNotifier(0);
ui.Image? frameAt(int index) => null;
}
class RlottieEngine {
RlottieEngine._();
static final RlottieEngine instance = RlottieEngine._();
static String? debugLibraryPath;
bool get available => false;
Future<RlottieClip?> acquire(String url, int px, {String? inlineJson}) async =>
null;
Future<void> prewarm(String url, int px) async {}
void release(RlottieClip clip) {}
}
+112
View File
@@ -0,0 +1,112 @@
import 'dart:ffi';
import 'dart:io';
import 'package:ffi/ffi.dart';
typedef _InitNative = Void Function();
typedef _VoidFn = void Function();
typedef _FromDataNative = Pointer<Void> Function(
Pointer<Utf8>, Pointer<Utf8>, Pointer<Utf8>);
typedef _SizeGetterNative = Size Function(Pointer<Void>);
typedef _SizeGetter = int Function(Pointer<Void>);
typedef _DoubleGetterNative = Double Function(Pointer<Void>);
typedef _DoubleGetter = double Function(Pointer<Void>);
typedef _RenderNative = Void Function(
Pointer<Void>, Size, Pointer<Uint32>, Size, Size, Size);
typedef _Render = void Function(
Pointer<Void>, int, Pointer<Uint32>, int, int, int);
typedef _DestroyNative = Void Function(Pointer<Void>);
typedef _Destroy = void Function(Pointer<Void>);
typedef _CacheSizeNative = Void Function(Size);
typedef _CacheSize = void Function(int);
class RlottieBindings {
RlottieBindings._(this._lib) {
_init = _lib.lookupFunction<_InitNative, _VoidFn>('lottie_init');
_shutdown = _lib.lookupFunction<_InitNative, _VoidFn>('lottie_shutdown');
_fromData = _lib.lookupFunction<_FromDataNative, _FromDataNative>(
'lottie_animation_from_data');
_totalFrame = _lib.lookupFunction<_SizeGetterNative, _SizeGetter>(
'lottie_animation_get_totalframe');
_frameRate = _lib.lookupFunction<_DoubleGetterNative, _DoubleGetter>(
'lottie_animation_get_framerate');
_duration = _lib.lookupFunction<_DoubleGetterNative, _DoubleGetter>(
'lottie_animation_get_duration');
_render =
_lib.lookupFunction<_RenderNative, _Render>('lottie_animation_render');
_destroy = _lib
.lookupFunction<_DestroyNative, _Destroy>('lottie_animation_destroy');
_cacheSize = _lib.lookupFunction<_CacheSizeNative, _CacheSize>(
'lottie_configure_model_cache_size');
_init();
}
final DynamicLibrary _lib;
late final _VoidFn _init;
late final _VoidFn _shutdown;
late final _FromDataNative _fromData;
late final _SizeGetter _totalFrame;
late final _DoubleGetter _frameRate;
late final _DoubleGetter _duration;
late final _Render _render;
late final _Destroy _destroy;
late final _CacheSize _cacheSize;
static RlottieBindings? open({String? path}) {
try {
final lib = _openLibrary(path);
return lib == null ? null : RlottieBindings._(lib);
} catch (_) {
return null;
}
}
static DynamicLibrary? _openLibrary(String? path) {
if (path != null) return DynamicLibrary.open(path);
if (Platform.isMacOS || Platform.isIOS) return DynamicLibrary.process();
try {
return DynamicLibrary.open(rlottieLibraryName);
} catch (_) {
return null;
}
}
Pointer<Void>? loadFromData(String data, String key) {
final dataC = data.toNativeUtf8();
final keyC = key.toNativeUtf8();
final resC = ''.toNativeUtf8();
try {
final anim = _fromData(dataC, keyC, resC);
return anim == nullptr ? null : anim;
} finally {
calloc.free(dataC);
calloc.free(keyC);
calloc.free(resC);
}
}
int totalFrame(Pointer<Void> anim) => _totalFrame(anim);
double frameRate(Pointer<Void> anim) => _frameRate(anim);
double duration(Pointer<Void> anim) => _duration(anim);
void render(Pointer<Void> anim, int frameNo, Pointer<Uint32> buffer, int px) {
_render(anim, frameNo, buffer, px, px, px * 4);
}
void destroy(Pointer<Void> anim) => _destroy(anim);
void configureModelCache(int bytes) => _cacheSize(bytes);
void shutdown() => _shutdown();
}
String get rlottieLibraryName {
if (Platform.isWindows) return 'rlottie.dll';
return 'librlottie.so';
}
+153
View File
@@ -0,0 +1,153 @@
import 'dart:ffi';
import 'dart:isolate';
import 'dart:typed_data';
import 'package:ffi/ffi.dart';
import 'rlottie_ffi.dart';
class RenderJob {
const RenderJob({
required this.jobId,
required this.json,
required this.cacheKey,
required this.px,
this.libPath,
});
final int jobId;
final String json;
final String cacheKey;
final int px;
final String? libPath;
}
class ClipMeta {
const ClipMeta({
required this.jobId,
required this.totalFrame,
required this.frameRate,
required this.durationMs,
});
final int jobId;
final int totalFrame;
final double frameRate;
final int durationMs;
}
class RenderedFrame {
const RenderedFrame({
required this.jobId,
required this.index,
required this.data,
required this.px,
});
final int jobId;
final int index;
final TransferableTypedData data;
final int px;
}
class RenderDone {
const RenderDone(this.jobId);
final int jobId;
}
class RenderError {
const RenderError(this.jobId, this.message);
final int jobId;
final String message;
}
class CancelJob {
const CancelJob(this.jobId);
final int jobId;
}
void rlottieWorkerMain(SendPort toMain) {
final port = ReceivePort();
toMain.send(port.sendPort);
final cancelled = <int>{};
RlottieBindings? bindings;
String? boundLibPath;
port.listen((message) {
if (message is CancelJob) {
cancelled.add(message.jobId);
return;
}
if (message is! RenderJob) return;
final job = message;
cancelled.remove(job.jobId);
if (bindings == null || boundLibPath != job.libPath) {
bindings = RlottieBindings.open(path: job.libPath);
boundLibPath = job.libPath;
}
final rl = bindings;
if (rl == null) {
toMain.send(RenderError(job.jobId, 'rlottie unavailable'));
return;
}
final anim = rl.loadFromData(job.json, job.cacheKey);
if (anim == null) {
toMain.send(RenderError(job.jobId, 'parse failed'));
return;
}
try {
final total = rl.totalFrame(anim);
final fps = rl.frameRate(anim);
final durationMs = fps <= 0 ? 1000 : (total / fps * 1000).round();
toMain.send(ClipMeta(
jobId: job.jobId,
totalFrame: total,
frameRate: fps,
durationMs: durationMs,
));
final px = job.px;
final buffer = calloc<Uint32>(px * px);
try {
for (var i = 0; i < total; i++) {
if (cancelled.contains(job.jobId)) break;
rl.render(anim, i, buffer, px);
final rgba = _bgraToRgba(buffer, px * px);
toMain.send(RenderedFrame(
jobId: job.jobId,
index: i,
data: TransferableTypedData.fromList([rgba]),
px: px,
));
}
} finally {
calloc.free(buffer);
}
toMain.send(RenderDone(job.jobId));
} catch (e) {
toMain.send(RenderError(job.jobId, e.toString()));
} finally {
rl.destroy(anim);
cancelled.remove(job.jobId);
}
});
}
Uint8List _bgraToRgba(Pointer<Uint32> buffer, int pixels) {
final src = buffer.asTypedList(pixels);
final out = Uint8List(pixels * 4);
for (var i = 0; i < pixels; i++) {
final v = src[i];
final o = i * 4;
out[o] = (v >> 16) & 0xff;
out[o + 1] = (v >> 8) & 0xff;
out[o + 2] = v & 0xff;
out[o + 3] = (v >> 24) & 0xff;
}
return out;
}