merge: 合并 K65-A 冷启动首页预取
This commit is contained in:
commit
c76b388814
7 files changed
+255
-113
No files matched your search
@@ -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'
|
||||
|
||||
@@ -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
@@ -13,6 +13,6 @@ Future<void> main() async {
|
||||
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]),
|
||||
ServerEndpoint.load(),
|
||||
]);
|
||||
await AppConfig.probeAndApplyBaseUrl();
|
||||
AppConfig.ready = AppConfig.probeAndApplyBaseUrl();
|
||||
runApp(const LumaDramaApp());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user