merge: 合并 K65-A 冷启动首页预取

This commit is contained in:
Zhou Weidong committed 2026-10-20 19:51:55 +08:00
commit c76b388814
7 files changed
+255 -113

No files matched your search

+15 -4
View File
@@ -28,6 +28,12 @@ class AppConfig {
static String _runtimeBaseUrl = '';
static Future<void>? _ready;
/// 启动地址探测完成信号;默认已完成,避免影响既有测试与调用方。
static Future<void> get ready => _ready ?? Future<void>.value();
static set ready(Future<void> value) => _ready = value;
static const Duration _probeTimeout = Duration(seconds: 2);
static const List<String> _probeCandidates = [
lanBaseUrl,
@@ -85,10 +91,15 @@ class AppConfig {
for (final candidate in _probeCandidates)
if (candidate != _runtimeBaseUrl) candidate,
];
for (final candidate in candidates) {
final result = probe == null
? await _probeBaseUrl(candidate)
: await _runInjectedProbe(candidate, probe);
final results = await Future.wait([
for (final candidate in candidates)
probe == null
? _probeBaseUrl(candidate)
: _runInjectedProbe(candidate, probe),
]);
for (var index = 0; index < candidates.length; index++) {
final candidate = candidates[index];
final result = results[index];
if (kDebugMode) {
final status = result.ok
? 'ok'
+11 -2
View File
@@ -21,7 +21,7 @@ class ApiClient {
this.tokenRefresher,
this.onUnauthorized,
this.deviceIdReader,
}) : baseUrl = baseUrl ?? AppConfig.apiBaseUrl {
}) : _fixedBaseUrl = baseUrl {
_dio = Dio(
BaseOptions(
baseUrl: this.baseUrl,
@@ -45,9 +45,12 @@ class ApiClient {
}
}
final String baseUrl;
final String? _fixedBaseUrl;
late final Dio _dio;
/// 当前请求使用的服务地址;未显式指定时跟随启动探测结果。
String get baseUrl => _fixedBaseUrl ?? AppConfig.apiBaseUrl;
/// 取当前 access token(未登录返回 null)
final String? Function()? accessTokenReader;
@@ -110,6 +113,7 @@ class ApiClient {
bool retried = false,
bool skipAuthRetry = false,
}) async {
_syncBaseUrl();
final disableAuthRetry = skipAuthRetry || tokenRefresher == null;
try {
final response = await _dio.request<Object?>(
@@ -150,5 +154,10 @@ class ApiClient {
}
}
void _syncBaseUrl() {
if (_fixedBaseUrl != null) return;
_dio.options.baseUrl = AppConfig.apiBaseUrl;
}
void close() => _dio.close(force: true);
}
@@ -35,6 +35,7 @@ class CatalogController extends ChangeNotifier {
debugError = null;
notifyListeners();
try {
await AppConfig.ready;
feed = await repository.fetchHomeFeed();
} on ApiException catch (exception) {
error = exception.message;
+1 -1
View File
@@ -13,6 +13,6 @@ Future<void> main() async {
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
ServerEndpoint.load(),
]);
await AppConfig.probeAndApplyBaseUrl();
AppConfig.ready = AppConfig.probeAndApplyBaseUrl();
runApp(const LumaDramaApp());
}
+89 -45
View File
@@ -1,61 +1,102 @@
// Author: fruit.com.cn
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/data/models/drama.dart';
import 'package:fruitshorts/core/data/models/home_feed.dart';
import 'package:fruitshorts/core/data/repositories/catalog_repository.dart';
import 'package:fruitshorts/core/data/services/catalog_controller.dart';
import 'package:fruitshorts/core/config/app_config.dart';
void main() {
test('should_distinguish_empty_catalog_from_load_failure_when_retry_succeeds', () async {
setUp(() {
AppConfig.clearRuntimeBaseUrl();
AppConfig.ready = Future<void>.value();
});
tearDown(() {
AppConfig.clearRuntimeBaseUrl();
AppConfig.ready = Future<void>.value();
});
test(
'should_distinguish_empty_catalog_from_load_failure_when_retry_succeeds',
() async {
final repository = ScriptedCatalogRepository();
final controller = CatalogController(repository);
expect(controller.settled, isFalse);
await controller.load();
expect(controller.settled, isTrue);
expect(controller.dramas, isEmpty);
expect(controller.error, isNull);
repository.fail = true;
await controller.load();
expect(controller.error, isNotNull);
repository.fail = false;
repository.feed = HomeFeed(
sections: [
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
],
);
await controller.load();
expect(controller.error, isNull);
expect(controller.dramas, hasLength(1));
expect(controller.sections.single.title, '热门推荐');
},
);
test(
'should_request_drama_detail_once_when_it_is_loaded_on_demand',
() async {
final repository = ScriptedCatalogRepository();
final controller = CatalogController(repository);
repository.feed = HomeFeed(
sections: [
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
],
);
await controller.load();
final card = controller.dramas.single;
expect(card.episodes, isEmpty);
final detail = await controller.requireDetail(card);
expect(detail.episodes, hasLength(2));
expect(repository.detailCalls, 1);
// 第二次已经是详情,直接复用,不再请求
await controller.requireDetail(detail);
expect(repository.detailCalls, 1);
expect(controller.dramas.single.episodes, hasLength(2));
},
);
test('should_wait_for_ready_and_use_final_endpoint_before_loading', () async {
const finalBaseUrl = 'http://final.test:8081';
final ready = Completer<void>();
AppConfig.ready = ready.future;
final repository = ScriptedCatalogRepository();
final controller = CatalogController(repository);
expect(controller.settled, isFalse);
await controller.load();
final loading = controller.load();
await Future<void>.delayed(Duration.zero);
expect(controller.loading, isTrue);
expect(repository.homeCalls, 0);
AppConfig.applyRuntimeBaseUrl(finalBaseUrl);
ready.complete();
await loading;
expect(repository.homeCalls, 1);
expect(repository.requestBaseUrl, finalBaseUrl);
expect(controller.loading, isFalse);
expect(controller.settled, isTrue);
expect(controller.dramas, isEmpty);
expect(controller.error, isNull);
repository.fail = true;
await controller.load();
expect(controller.error, isNotNull);
repository.fail = false;
repository.feed = HomeFeed(
sections: [
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
],
);
await controller.load();
expect(controller.error, isNull);
expect(controller.dramas, hasLength(1));
expect(controller.sections.single.title, '热门推荐');
});
test('should_request_drama_detail_once_when_it_is_loaded_on_demand', () async {
final repository = ScriptedCatalogRepository();
final controller = CatalogController(repository);
repository.feed = HomeFeed(
sections: [
FeedSection(code: 'hot', title: '热门推荐', dramas: [_card('1')]),
],
);
await controller.load();
final card = controller.dramas.single;
expect(card.episodes, isEmpty);
final detail = await controller.requireDetail(card);
expect(detail.episodes, hasLength(2));
expect(repository.detailCalls, 1);
// 第二次已经是详情,直接复用,不再请求
await controller.requireDetail(detail);
expect(repository.detailCalls, 1);
expect(controller.dramas.single.episodes, hasLength(2));
});
}
Drama _card(String id) => Drama(
@@ -81,9 +122,13 @@ class ScriptedCatalogRepository implements CatalogRepository {
bool fail = false;
HomeFeed feed = const HomeFeed();
int detailCalls = 0;
int homeCalls = 0;
String? requestBaseUrl;
@override
Future<HomeFeed> fetchHomeFeed() async {
homeCalls++;
requestBaseUrl = AppConfig.apiBaseUrl;
if (fail) throw StateError('offline');
return feed;
}
@@ -96,5 +141,4 @@ class ScriptedCatalogRepository implements CatalogRepository {
detailCalls++;
return _detail(dramaId);
}
}
+44 -14
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart';
@@ -11,9 +13,13 @@ void main() {
SharedPreferencesAsyncPlatform.instance =
InMemorySharedPreferencesAsync.empty();
AppConfig.clearRuntimeBaseUrl();
AppConfig.ready = Future<void>.value();
});
tearDown(AppConfig.clearRuntimeBaseUrl);
tearDown(() {
AppConfig.clearRuntimeBaseUrl();
AppConfig.ready = Future<void>.value();
});
test('should_normalize_http_urls_and_reject_invalid_values', () {
expect(
@@ -60,20 +66,39 @@ void main() {
expect(AppConfig.devHostBaseUrl, 'http://192.168.31.187:8081');
});
test('should_keep_loopback_priority_when_probe_succeeds', () async {
final attempts = <String>[];
test(
'should_probe_all_candidates_in_parallel_before_applying_priority',
() async {
final attempts = <String>[];
var active = 0;
var maxActive = 0;
final allStarted = Completer<void>();
await AppConfig.probeAndApplyBaseUrl(
fallbackBaseUrl: AppConfig.devHostBaseUrl,
probe: (baseUrl) async {
attempts.add(baseUrl);
return baseUrl == AppConfig.lanBaseUrl;
},
);
final probing = AppConfig.probeAndApplyBaseUrl(
fallbackBaseUrl: AppConfig.devHostBaseUrl,
probe: (baseUrl) async {
attempts.add(baseUrl);
active++;
if (active > maxActive) maxActive = active;
if (active == 3) allStarted.complete();
await allStarted.future;
active--;
return baseUrl == AppConfig.lanBaseUrl ||
baseUrl == AppConfig.devHostBaseUrl;
},
);
expect(attempts, [AppConfig.lanBaseUrl]);
expect(AppConfig.apiBaseUrl, AppConfig.lanBaseUrl);
});
await probing.timeout(const Duration(seconds: 1));
expect(attempts, [
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(maxActive, 3);
expect(AppConfig.apiBaseUrl, AppConfig.lanBaseUrl);
},
);
test('should_probe_loopback_dev_host_then_public_in_order', () async {
final attempts = <String>[];
@@ -125,7 +150,12 @@ void main() {
},
);
expect(attempts, [custom]);
expect(attempts, [
custom,
AppConfig.lanBaseUrl,
AppConfig.devHostBaseUrl,
AppConfig.publicBaseUrl,
]);
expect(AppConfig.apiBaseUrl, custom);
});
+94 -47
View File
@@ -4,6 +4,7 @@ import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:fruitshorts/core/config/app_config.dart';
import 'package:fruitshorts/core/data/remote/api_client.dart';
import '../support/fake_http_adapter.dart';
@@ -18,63 +19,87 @@ void main() {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope({'id': 7})),
);
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
);
final data = await client.get('/app/dramas/7');
expect((data as Map)['id'], 7);
});
test('should_throw_api_exception_with_server_message_when_envelope_code_is_not_zero', () async {
test(
'should_throw_api_exception_with_server_message_when_envelope_code_is_not_zero',
() async {
final adapter = FakeHttpAdapter(
(_) async =>
FakeHttpResponse(envelope(null, code: 40001, message: '邮箱格式不正确')),
);
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
);
await expectLater(
client.post('/app/auth/register', body: {'target': 'bad'}),
throwsA(
isA<ApiException>()
.having((e) => e.code, 'code', 40001)
.having((e) => e.message, 'message', '邮箱格式不正确'),
),
);
},
);
test(
'should_map_to_api_exception_with_http_status_when_body_has_no_envelope',
() async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse('oops', 502),
);
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
);
await expectLater(
client.get('/app/home/feed'),
throwsA(isA<ApiException>().having((e) => e.code, 'code', 502)),
);
},
);
test(
'should_send_bearer_token_and_device_header_when_both_are_available',
() async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope(null)),
);
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
accessTokenReader: () => 'token-1',
deviceIdReader: () async => 'device-9',
);
await client.get('/app/me');
final request = adapter.requests.single;
expect(request.headers['Authorization'], 'Bearer token-1');
expect(request.headers['X-Device-Id'], 'device-9');
expect(request.headers['Accept'], 'application/json');
},
);
test('should_put_query_map_into_request_uri_when_query_is_given', () async {
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(
envelope(null, code: 40001, message: '邮箱格式不正确'),
),
(_) async => FakeHttpResponse(envelope(null)),
);
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
await expectLater(
client.post('/app/auth/register', body: {'target': 'bad'}),
throwsA(
isA<ApiException>()
.having((e) => e.code, 'code', 40001)
.having((e) => e.message, 'message', '邮箱格式不正确'),
),
);
});
test('should_map_to_api_exception_with_http_status_when_body_has_no_envelope', () async {
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse('oops', 502));
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
await expectLater(
client.get('/app/home/feed'),
throwsA(
isA<ApiException>().having((e) => e.code, 'code', 502),
),
);
});
test('should_send_bearer_token_and_device_header_when_both_are_available', () async {
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null)));
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
adapter: adapter,
accessTokenReader: () => 'token-1',
deviceIdReader: () async => 'device-9',
);
await client.get('/app/me');
final request = adapter.requests.single;
expect(request.headers['Authorization'], 'Bearer token-1');
expect(request.headers['X-Device-Id'], 'device-9');
expect(request.headers['Accept'], 'application/json');
});
test('should_put_query_map_into_request_uri_when_query_is_given', () async {
final adapter = FakeHttpAdapter((_) async => FakeHttpResponse(envelope(null)));
final client = ApiClient(baseUrl: 'http://127.0.0.1:8081', adapter: adapter);
await client.get('/app/search', query: {'keyword': '短剧', 'page': '2'});
final uri = adapter.requests.single.uri;
@@ -86,7 +111,8 @@ void main() {
test('should_skip_refresh_when_call_opts_out_of_auth_retry', () async {
var refreshed = false;
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope(null, code: 401, message: '登录已过期')),
(_) async =>
FakeHttpResponse(envelope(null, code: 401, message: '登录已过期')),
);
final client = ApiClient(
baseUrl: 'http://127.0.0.1:8081',
@@ -98,10 +124,31 @@ void main() {
);
await expectLater(
client.post('/app/auth/refresh', body: {'refreshToken': 'r1'}, skipAuthRetry: true),
client.post(
'/app/auth/refresh',
body: {'refreshToken': 'r1'},
skipAuthRetry: true,
),
throwsA(isA<ApiException>().having((e) => e.code, 'code', 401)),
);
expect(refreshed, isFalse);
expect(adapter.requests, hasLength(1));
});
test(
'should_use_probed_endpoint_when_client_was_created_before_probe',
() async {
const probedBaseUrl = 'http://probed.test:8081';
final adapter = FakeHttpAdapter(
(_) async => FakeHttpResponse(envelope(null)),
);
final client = ApiClient(adapter: adapter);
AppConfig.applyRuntimeBaseUrl(probedBaseUrl);
addTearDown(AppConfig.clearRuntimeBaseUrl);
await client.get('/app/home/feed');
expect(adapter.requests.single.uri.origin, probedBaseUrl);
},
);
}