|
@@ -3,33 +3,30 @@ import 'dart:ui' as ui;
|
|
|
|
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:flutter/foundation.dart';
|
|
|
import 'package:http/http.dart';
|
|
import 'package:http/http.dart';
|
|
|
-import 'package:puzzleweave/config/device.dart';
|
|
|
|
|
|
|
+import 'package:logging/logging.dart';
|
|
|
|
|
+import 'package:puzzleweave/models/api_helper.dart';
|
|
|
|
|
+import 'package:puzzleweave/models/data.dart';
|
|
|
import 'package:puzzleweave/models/items.dart';
|
|
import 'package:puzzleweave/models/items.dart';
|
|
|
import 'package:puzzleweave/utils/utils.dart';
|
|
import 'package:puzzleweave/utils/utils.dart';
|
|
|
-import 'package:logging/logging.dart';
|
|
|
|
|
|
|
|
|
|
final Logger _log = Logger('download.dart');
|
|
final Logger _log = Logger('download.dart');
|
|
|
|
|
|
|
|
/// 最多缓存/并发下载n个图到内存
|
|
/// 最多缓存/并发下载n个图到内存
|
|
|
const maxCachedItems = 1;
|
|
const maxCachedItems = 1;
|
|
|
|
|
|
|
|
-/// Sigeleton
|
|
|
|
|
|
|
+/// Singleton
|
|
|
class Download {
|
|
class Download {
|
|
|
static final Download _instance = Download._internal();
|
|
static final Download _instance = Download._internal();
|
|
|
|
|
|
|
|
- factory Download() {
|
|
|
|
|
- return _instance;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ factory Download() => _instance;
|
|
|
Download._internal();
|
|
Download._internal();
|
|
|
|
|
|
|
|
final Map<String, DownloadItem> _cache = {};
|
|
final Map<String, DownloadItem> _cache = {};
|
|
|
|
|
|
|
|
- DownloadItem download(url, cachePath) {
|
|
|
|
|
- // 移除同步 _clean() 调用,避免干扰正在启动的下载序列
|
|
|
|
|
- // _clean();
|
|
|
|
|
|
|
+ DownloadItem download(String url, String cachePath) {
|
|
|
if (_cache[url] != null) {
|
|
if (_cache[url] != null) {
|
|
|
_log.info('Cache hit for $url');
|
|
_log.info('Cache hit for $url');
|
|
|
- _cache[url]!.touch(); //update last use time.
|
|
|
|
|
|
|
+ _cache[url]!.touch();
|
|
|
return _cache[url]!;
|
|
return _cache[url]!;
|
|
|
} else {
|
|
} else {
|
|
|
final item = DownloadItem(url, cachePath);
|
|
final item = DownloadItem(url, cachePath);
|
|
@@ -53,11 +50,7 @@ class Download {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
_clean() {
|
|
_clean() {
|
|
|
- // final list = _cache.values.toList();
|
|
|
|
|
- // 1. 筛选出可以被清理的项:必须是已完成加载(已完成下载并写入磁盘)
|
|
|
|
|
- final list = _cache.values
|
|
|
|
|
- .where((item) => item.loadCompleter.isCompleted) // !!! 修正点 2: 仅清理已完成加载的项
|
|
|
|
|
- .toList();
|
|
|
|
|
|
|
+ final list = _cache.values.where((item) => item.loadCompleter.isCompleted).toList();
|
|
|
if (list.length <= maxCachedItems) return;
|
|
if (list.length <= maxCachedItems) return;
|
|
|
_log.info('cleaning...');
|
|
_log.info('cleaning...');
|
|
|
|
|
|
|
@@ -66,7 +59,7 @@ class Download {
|
|
|
// 3. 清理到只剩下 maxCachedItems 个
|
|
// 3. 清理到只剩下 maxCachedItems 个
|
|
|
while (list.length > maxCachedItems) {
|
|
while (list.length > maxCachedItems) {
|
|
|
final item = list.removeAt(0);
|
|
final item = list.removeAt(0);
|
|
|
- _log.info('clean item: $item');
|
|
|
|
|
|
|
+ _log.info('Cleaning item from memory: $item');
|
|
|
item.dispose();
|
|
item.dispose();
|
|
|
_cache.remove(item.url);
|
|
_cache.remove(item.url);
|
|
|
}
|
|
}
|
|
@@ -82,165 +75,101 @@ class DownloadItem {
|
|
|
final String url;
|
|
final String url;
|
|
|
final String cachePath;
|
|
final String cachePath;
|
|
|
ValueNotifier<double> progress = ValueNotifier(0.0);
|
|
ValueNotifier<double> progress = ValueNotifier(0.0);
|
|
|
- final Completer<ui.Image> loadCompleter = Completer();
|
|
|
|
|
|
|
+ final Completer<void> loadCompleter = Completer(); // 仅作为完成信号
|
|
|
Client? client;
|
|
Client? client;
|
|
|
StreamSubscription? subscription;
|
|
StreamSubscription? subscription;
|
|
|
int lastUsed;
|
|
int lastUsed;
|
|
|
- int size = 0;
|
|
|
|
|
- ui.Image? image;
|
|
|
|
|
- Uint8List? data;
|
|
|
|
|
|
|
+ Uint8List? _data;
|
|
|
|
|
|
|
|
DownloadItem(this.url, this.cachePath) : lastUsed = DateTime.now().millisecondsSinceEpoch {
|
|
DownloadItem(this.url, this.cachePath) : lastUsed = DateTime.now().millisecondsSinceEpoch {
|
|
|
_log.info('New download item for: $url');
|
|
_log.info('New download item for: $url');
|
|
|
_start();
|
|
_start();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- touch() {
|
|
|
|
|
- lastUsed = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ Uint8List? get data => _data;
|
|
|
|
|
+ set data(Uint8List? val) => _data = val;
|
|
|
|
|
+
|
|
|
|
|
+ touch() => lastUsed = DateTime.now().millisecondsSinceEpoch;
|
|
|
|
|
|
|
|
_start() async {
|
|
_start() async {
|
|
|
try {
|
|
try {
|
|
|
- final image = await _download();
|
|
|
|
|
- loadCompleter.complete(image);
|
|
|
|
|
|
|
+ await _download();
|
|
|
|
|
+ if (!loadCompleter.isCompleted) loadCompleter.complete();
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
- loadCompleter.completeError(err);
|
|
|
|
|
|
|
+ if (!loadCompleter.isCompleted) loadCompleter.completeError(err);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- Future<ui.Image> _download() async {
|
|
|
|
|
|
|
+ Future<void> _download() async {
|
|
|
progress.value = 0;
|
|
progress.value = 0;
|
|
|
-
|
|
|
|
|
final file = await localFile(cachePath);
|
|
final file = await localFile(cachePath);
|
|
|
_checkDispose();
|
|
_checkDispose();
|
|
|
|
|
|
|
|
- final Uint8List data;
|
|
|
|
|
- bool shouldSave = false;
|
|
|
|
|
-
|
|
|
|
|
- //if (await file.exists()) {
|
|
|
|
|
|
|
+ // 核心改造:如果文件存在,只报完成,不读数据 (Lazy Load)
|
|
|
if (await file.exists()) {
|
|
if (await file.exists()) {
|
|
|
- _log.info('Disk cache hit..');
|
|
|
|
|
- data = await file.readAsBytes();
|
|
|
|
|
- _checkDispose();
|
|
|
|
|
- progress.value = 1;
|
|
|
|
|
- } else {
|
|
|
|
|
- final List<int> bytes = [];
|
|
|
|
|
-
|
|
|
|
|
- client = Client();
|
|
|
|
|
-
|
|
|
|
|
- final uri = Uri.parse(url);
|
|
|
|
|
- final request = Request('GET', uri);
|
|
|
|
|
-
|
|
|
|
|
- final response = await client!.send(request);
|
|
|
|
|
- _checkDispose();
|
|
|
|
|
-
|
|
|
|
|
- if (response.statusCode != 200) {
|
|
|
|
|
- throw Exception('Download error, stauts:${response.statusCode}, url=$uri');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- if (response.contentLength == null) {
|
|
|
|
|
- throw Exception('Download error, no length, url=$uri');
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- final length = response.contentLength!;
|
|
|
|
|
-
|
|
|
|
|
- _log.info('message: contentLength=$length');
|
|
|
|
|
-
|
|
|
|
|
- final streamCompleter = Completer();
|
|
|
|
|
-
|
|
|
|
|
- subscription = response.stream.listen(
|
|
|
|
|
- (value) {
|
|
|
|
|
- try {
|
|
|
|
|
- // 有可能内存溢出, 先try/catch包一下
|
|
|
|
|
- bytes.addAll(value);
|
|
|
|
|
- progress.value = bytes.length / length;
|
|
|
|
|
- _log.info('message: progress=${progress.value}');
|
|
|
|
|
- } catch (e) {
|
|
|
|
|
- _log.warning("Out of memory: $e");
|
|
|
|
|
- // FirebaseCrashlytics.instance.log("OOM from download url: $uri, error: $e");
|
|
|
|
|
- streamCompleter.completeError(e);
|
|
|
|
|
- }
|
|
|
|
|
- },
|
|
|
|
|
- onDone: () {
|
|
|
|
|
- //_log.info('onDone..');
|
|
|
|
|
- streamCompleter.complete();
|
|
|
|
|
- },
|
|
|
|
|
- onError: (e) {
|
|
|
|
|
- _log.info('onError: $e');
|
|
|
|
|
- streamCompleter.completeError(e);
|
|
|
|
|
- },
|
|
|
|
|
- cancelOnError: true,
|
|
|
|
|
- );
|
|
|
|
|
-
|
|
|
|
|
- await streamCompleter.future;
|
|
|
|
|
- //await response.stream.first;
|
|
|
|
|
- _log.info('xxxxxxxxxxxxxxxxxxxx stream complete');
|
|
|
|
|
-
|
|
|
|
|
- _checkDispose();
|
|
|
|
|
- _log.info('message: download succeed, progress=$progress, length=${bytes.length}');
|
|
|
|
|
-
|
|
|
|
|
- bytes.removeRange(0, 24);
|
|
|
|
|
- data = Uint8List.fromList(bytes);
|
|
|
|
|
-
|
|
|
|
|
- shouldSave = true;
|
|
|
|
|
- _checkDispose();
|
|
|
|
|
|
|
+ _log.info('Disk cache hit (Metadata only) for $cachePath');
|
|
|
|
|
+ progress.value = 1.0;
|
|
|
|
|
+ return;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- _log.info('message: realbytes: ${data.length}');
|
|
|
|
|
-
|
|
|
|
|
- int size = Device.physicalSize.width.toInt();
|
|
|
|
|
-
|
|
|
|
|
- final ui.Codec codec = await ui.instantiateImageCodec(data, targetHeight: size, targetWidth: size);
|
|
|
|
|
- final ui.FrameInfo frameInfo = await codec.getNextFrame();
|
|
|
|
|
- final image = frameInfo.image;
|
|
|
|
|
- this.image = image;
|
|
|
|
|
|
|
+ // 网络下载逻辑
|
|
|
|
|
+ final List<int> bytes = [];
|
|
|
|
|
+ client = Client();
|
|
|
|
|
+ final response = await client!.send(Request('GET', Uri.parse(url)));
|
|
|
|
|
+ _checkDispose();
|
|
|
|
|
|
|
|
- this.data = data;
|
|
|
|
|
- size = data.length;
|
|
|
|
|
|
|
+ if (response.statusCode != 200) throw Exception('Status:${response.statusCode}');
|
|
|
|
|
+ final length = response.contentLength ?? 0;
|
|
|
|
|
+
|
|
|
|
|
+ final streamCompleter = Completer();
|
|
|
|
|
+ subscription = response.stream.listen(
|
|
|
|
|
+ (value) {
|
|
|
|
|
+ bytes.addAll(value);
|
|
|
|
|
+ if (length > 0) progress.value = bytes.length / length;
|
|
|
|
|
+ },
|
|
|
|
|
+ onDone: () => streamCompleter.complete(),
|
|
|
|
|
+ onError: (e) => streamCompleter.completeError(e),
|
|
|
|
|
+ cancelOnError: true,
|
|
|
|
|
+ );
|
|
|
|
|
+
|
|
|
|
|
+ await streamCompleter.future;
|
|
|
|
|
+ _checkDispose();
|
|
|
|
|
|
|
|
- if (shouldSave) {
|
|
|
|
|
- await saveBytes(cachePath, data);
|
|
|
|
|
|
|
+ // 剔除 24 字节干扰码
|
|
|
|
|
+ if (bytes.length > 24) {
|
|
|
|
|
+ bytes.removeRange(0, 24);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- _checkDispose();
|
|
|
|
|
|
|
+ _data = Uint8List.fromList(bytes);
|
|
|
|
|
+ await saveBytes(cachePath, _data!); // 写入磁盘
|
|
|
|
|
+ _log.info('Download and save complete for $url');
|
|
|
|
|
|
|
|
- _log.info('image: ${image.width}x${image.height}');
|
|
|
|
|
client?.close();
|
|
client?.close();
|
|
|
-
|
|
|
|
|
- return image;
|
|
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- Future<ui.Image> getImageBySize(int dim, {allowUpscaling = false}) async {
|
|
|
|
|
- await loadCompleter.future;
|
|
|
|
|
- final ui.Codec codec = await ui.instantiateImageCodec(data!, targetHeight: dim, targetWidth: dim, allowUpscaling: allowUpscaling);
|
|
|
|
|
- final ui.FrameInfo frameInfo = await codec.getNextFrame();
|
|
|
|
|
- return frameInfo.image;
|
|
|
|
|
|
|
+ /// 供 Loader 真正需要数据时调用
|
|
|
|
|
+ Future<Uint8List> ensureDataLoaded() async {
|
|
|
|
|
+ if (_data != null) return _data!;
|
|
|
|
|
+ _log.info('Performing late read from disk: $cachePath');
|
|
|
|
|
+ final file = await localFile(cachePath);
|
|
|
|
|
+ _data = await file.readAsBytes();
|
|
|
|
|
+ return _data!;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
bool _isDisposed = false;
|
|
bool _isDisposed = false;
|
|
|
-
|
|
|
|
|
_checkDispose() {
|
|
_checkDispose() {
|
|
|
- _log.info('$this,checkDispose: $_isDisposed');
|
|
|
|
|
- if (_isDisposed) throw Exception('Request disposed');
|
|
|
|
|
|
|
+ if (_isDisposed) throw Exception('Disposed');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- dispose() async {
|
|
|
|
|
- _log.info('Disposing $this, client:$client');
|
|
|
|
|
|
|
+ dispose() {
|
|
|
_isDisposed = true;
|
|
_isDisposed = true;
|
|
|
- // do clean.
|
|
|
|
|
- try {
|
|
|
|
|
- subscription?.cancel();
|
|
|
|
|
- client?.close();
|
|
|
|
|
- image?.dispose();
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- _log.info('xxxxxxxxxxxx $error');
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ _data = null; // 释放内存
|
|
|
|
|
+ subscription?.cancel();
|
|
|
|
|
+ client?.close();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
@override
|
|
|
- String toString() {
|
|
|
|
|
- return '[$cachePath]';
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ String toString() => '[$cachePath]';
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
abstract class ItemLoader {
|
|
abstract class ItemLoader {
|
|
@@ -250,58 +179,53 @@ abstract class ItemLoader {
|
|
|
|
|
|
|
|
ItemLoader();
|
|
ItemLoader();
|
|
|
|
|
|
|
|
- Future<ui.Image> getImageBySize(int width, int height, {allowUpscaling = true}) async {
|
|
|
|
|
|
|
+ // 辅助方法:确保数据就绪后再解码
|
|
|
|
|
+ Future<Uint8List> _prepareData() async {
|
|
|
await completer.future;
|
|
await completer.future;
|
|
|
- final ui.Codec codec = await ui.instantiateImageCodec(data!, targetHeight: height, targetWidth: width, allowUpscaling: allowUpscaling);
|
|
|
|
|
- final ui.FrameInfo frameInfo = await codec.getNextFrame();
|
|
|
|
|
- return frameInfo.image;
|
|
|
|
|
|
|
+ if (data == null) throw 'Data missing after completion';
|
|
|
|
|
+ return data!;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ Future<ui.Image> getImageBySize(int width, int height, {allowUpscaling = true}) async {
|
|
|
|
|
+ final bytes = await _prepareData();
|
|
|
|
|
+ final codec = await ui.instantiateImageCodec(bytes, targetHeight: height, targetWidth: width, allowUpscaling: allowUpscaling);
|
|
|
|
|
+ return (await codec.getNextFrame()).image;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
Future<ui.Image> getImage() async {
|
|
Future<ui.Image> getImage() async {
|
|
|
- await completer.future;
|
|
|
|
|
- final ui.Codec codec = await ui.instantiateImageCodec(data!);
|
|
|
|
|
- final ui.FrameInfo frameInfo = await codec.getNextFrame();
|
|
|
|
|
- return frameInfo.image;
|
|
|
|
|
|
|
+ final bytes = await _prepareData();
|
|
|
|
|
+ final codec = await ui.instantiateImageCodec(bytes);
|
|
|
|
|
+ return (await codec.getNextFrame()).image;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /// 专门用于预加载的静态方法,不创建 Loader 实例
|
|
|
|
|
+ static void preload(ListItem item, String quality) {
|
|
|
|
|
+ if (BuiltinRegistry.contains(item.id)) {
|
|
|
|
|
+ _log.info('Preload: Skipping builtin ${item.id}');
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (item is RemoteItem) {
|
|
|
|
|
+ // 触发 Download 但不等待读入内存
|
|
|
|
|
+ Download().download(ApiHelper.imageUri(item.id, quality), item.cachePath);
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- factory ItemLoader.load(ListItem item) {
|
|
|
|
|
|
|
+ factory ItemLoader.load(ListItem item, String quality) {
|
|
|
|
|
+ if (BuiltinRegistry.contains(item.id)) {
|
|
|
|
|
+ _log.info('Built-in hit: ${item.id}, skip network.');
|
|
|
|
|
+ return AssetItemLoader('assets/builtin/${item.id}.jpeg');
|
|
|
|
|
+ }
|
|
|
switch (item.runtimeType) {
|
|
switch (item.runtimeType) {
|
|
|
case RemoteItem:
|
|
case RemoteItem:
|
|
|
- return RemoteItemLoader((item as RemoteItem).image, item.cachePath);
|
|
|
|
|
|
|
+ return RemoteItemLoader(ApiHelper.imageUri(item.id, quality), item.cachePath);
|
|
|
case AssetItem:
|
|
case AssetItem:
|
|
|
return AssetItemLoader((item as AssetItem).image);
|
|
return AssetItemLoader((item as AssetItem).image);
|
|
|
default:
|
|
default:
|
|
|
- throw 'Can\'t create ${item.runtimeType}';
|
|
|
|
|
|
|
+ throw 'Unknown item type';
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-class LocalItemLoader extends ItemLoader {
|
|
|
|
|
- final String path;
|
|
|
|
|
- Uint8List? _data;
|
|
|
|
|
-
|
|
|
|
|
- @override
|
|
|
|
|
- ValueNotifier<double> progress = ValueNotifier(0);
|
|
|
|
|
-
|
|
|
|
|
- LocalItemLoader(this.path) {
|
|
|
|
|
- _load();
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- _load() async {
|
|
|
|
|
- try {
|
|
|
|
|
- final file = await localFile(path);
|
|
|
|
|
- _data = await file.readAsBytes();
|
|
|
|
|
- completer.complete(data);
|
|
|
|
|
- progress.value = 1.0;
|
|
|
|
|
- } catch (error) {
|
|
|
|
|
- completer.completeError(error);
|
|
|
|
|
- }
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- @override
|
|
|
|
|
- Uint8List? get data => _data;
|
|
|
|
|
-}
|
|
|
|
|
-
|
|
|
|
|
class AssetItemLoader extends ItemLoader {
|
|
class AssetItemLoader extends ItemLoader {
|
|
|
final String path;
|
|
final String path;
|
|
|
Uint8List? _data;
|
|
Uint8List? _data;
|
|
@@ -316,10 +240,10 @@ class AssetItemLoader extends ItemLoader {
|
|
|
_load() async {
|
|
_load() async {
|
|
|
try {
|
|
try {
|
|
|
_data = await loadFileDataFromAsset(path);
|
|
_data = await loadFileDataFromAsset(path);
|
|
|
- completer.complete(data);
|
|
|
|
|
|
|
+ completer.complete();
|
|
|
progress.value = 1.0;
|
|
progress.value = 1.0;
|
|
|
- } catch (error) {
|
|
|
|
|
- completer.completeError(error);
|
|
|
|
|
|
|
+ } catch (e) {
|
|
|
|
|
+ completer.completeError(e);
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -340,6 +264,7 @@ class RemoteItemLoader extends ItemLoader {
|
|
|
_load() async {
|
|
_load() async {
|
|
|
try {
|
|
try {
|
|
|
await downloadItem.loadCompleter.future;
|
|
await downloadItem.loadCompleter.future;
|
|
|
|
|
+ // 在这里不读 data,getImage 时才读
|
|
|
completer.complete();
|
|
completer.complete();
|
|
|
} catch (err) {
|
|
} catch (err) {
|
|
|
completer.completeError(err);
|
|
completer.completeError(err);
|
|
@@ -347,7 +272,17 @@ class RemoteItemLoader extends ItemLoader {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
@override
|
|
@override
|
|
|
- Uint8List? get data => downloadItem.data;
|
|
|
|
|
|
|
+ Uint8List? get data {
|
|
|
|
|
+ // 同步获取(如果已读入内存),如果没读,需通过 getImage 异步触发
|
|
|
|
|
+ return downloadItem.data;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ @override
|
|
|
|
|
+ Future<Uint8List> _prepareData() async {
|
|
|
|
|
+ await completer.future;
|
|
|
|
|
+ // 关键点:如果内存里没数据(预加载命中的缓存),在此处执行补读
|
|
|
|
|
+ return await downloadItem.ensureDataLoaded();
|
|
|
|
|
+ }
|
|
|
|
|
|
|
|
@override
|
|
@override
|
|
|
ValueNotifier<double> get progress => downloadItem.progress;
|
|
ValueNotifier<double> get progress => downloadItem.progress;
|