Flutter 应用程序架构:鼓舞人心的领域驱动设计 -- 知识铺
Flutter 应用程序中结构良好的设计的重要性
∘ 简介 ∘ 表示层 ∘ 领域层 ∘ 基础设施层 ∘ 可测试性 ∘ 结论
介绍
编写健壮且可维护的 Flutter 应用程序不仅仅需要编写代码,还需要经过深思熟虑的架构。如果没有清晰的架构模式,您的代码库很快就会变得混乱,给维护和测试带来挑战。在 MVC、MVVM 等各种架构范例中,领域驱动设计 (DDD) 作为构建应用程序的强大方法脱颖而出。
DDD 专注于围绕业务领域而不是技术细节来组织软件,从而创建清晰且现实的架构。这不仅提高了代码的可维护性,而且与业务需求紧密结合,促进了协作开发过程。然而,由于其复杂性和陡峭的学习曲线,完全采用 DDD 可能具有挑战性。
在本文中,我们将探讨 Flutter 开发人员如何利用 DDD 启发的方法来实现更干净、更易于维护的代码库。我们将讨论 DDD 的好处,重点介绍其核心原则,并演示将 DDD 启发的架构集成到 Flutter 项目中的实用策略。让我们深入了解一下!
归功于原所有者。
在本文中,我们将通过实现身份验证功能来演示此架构。
表示层
表示层负责向用户显示数据并处理用户交互。它由两个主要组件组成:Widgets 和 Providers。
小部件:
Widget 是 Flutter UI 的构建块。它们负责呈现应用程序的视觉元素并响应用户输入。表示层中的小部件执行以下功能:
- 显示数据:小部件显示提供商提供的数据。
- 状态更改:它们侦听来自提供者的状态更改并相应地更新 UI。
- 用户交互:小部件处理用户交互,例如点击、滑动和其他手势。
提供商:
提供程序充当 Widget 和应用程序的业务逻辑之间的桥梁。它们管理状态并在状态更改时通知 Widget。表示层中的提供者执行以下功能:
- 可观察状态:提供者维护可由 Widget 使用的可观察状态。
- 通知监听器:当状态发生变化时,它们会通知 Widget,确保 UI 始终与数据同步,例如从存储库中获取数据,并将这些数据公开给 Widget。
在以下代码中,当用户单击登录按钮时,将调用 AuthNotifier 类的 SignIn 方法。 SignInScreen 侦听来自 authNotifierProvider 的状态更改。如果您不熟悉 Riverpod,请查看这篇文章。
class SignInScreen extends ConsumerWidget {
const SignInScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Listening to state changes and taking actions based on the state.
ref.listen(authNotifierProvider, (_, state) {
state.maybeWhen(
loading: () => LoadingOverlay.show(),
data: (auth) {
LoadingOverlay.hide();
},
error: (err, stack) {
LoadingOverlay.hide();
// show your error dialog.
},
orElse: () {},
);
});
return Scaffold(
body: Column(
children: [
// other widgets
// call mutate method.
EmailIconButton(
text: 'LogIn',
onPressed: () {
if (_formKey.currentState!.validate()) {
ref.read(authNotifierProvider.notifier).signIn(
emailController.text,
passwordController.text);
}
},
),
],
),
);
}
}
GuardX是guard(异步值方法)的扩展方法,可以自动将异常或错误转换为asyncError。 AuthNotifier 类向小部件内的侦听器通知状态更改。
@Riverpod(keepAlive: true)
class AuthNotifier extends _$AuthNotifier {
@override
FutureOr<Auth> build() async {
await Future.delayed(const Duration(seconds: 1));
final user = await ref.watch(authRepositoryProvider).isSignIn();
if (user == null) return const Auth.unauthenticated();
return Authenticated(user: user);
}
Future<void> signIn(String email, String password) async {
state = const AsyncValue.loading();
state = await state.guardX(() async {
final user =
await ref.watch(authRepositoryProvider).signIn(email, password);
if (user == null) return const Auth.unauthenticated();
return Auth.authenticated(user: user);
});
}
Future<void> signOut() async {
state = const AsyncValue.loading();
state = await state.guardX(() async {
await ref.watch(authRepositoryProvider).signOut();
return const Auth.unauthenticated();
});
}
}
总之,表示层对于确保响应式和交互式用户界面至关重要。 Widget 呈现 UI 并处理用户交互,而 Provider 管理状态并通知 Widget 任何更改。这种关注点分离可以实现干净且可维护的代码库,从而更轻松地管理 UI 和底层业务逻辑。
领域层
领域层是应用程序的核心,封装了业务逻辑和领域实体。它充当表示层和数据源之间的中介,确保业务规则和逻辑的应用一致。
part 'user.freezed.dart';
@freezed
abstract class User implements _$User {
const factory User({
@Default("") String id,
@Default("") String name,
@Default([]) List<String> permission,
}) = _User;
}
领域层由代表应用程序中核心业务对象的实体组成。它们封装了业务领域的关键属性和行为。在领域层,
- 封装业务逻辑:实体包含对应用程序至关重要的业务逻辑和规则。
- 领域驱动设计:实体是根据现实世界的业务需求设计的,反映了领域的结构和行为。
基础设施层
基础设施层通过提供必要的数据源和外部服务来支持应用程序。它与外部系统(例如 API 和本地数据库)交互,并处理原始数据,将其转换为表示层可用的实体。
基础设施层由存储库、数据传输对象 (DTO)、远程数据源和本地数据源组成。
数据传输对象 (DTO):
DTO 是在进程之间传输数据的简单对象。它们用于将数据从远程和本地数据源传输到域层中的实体。在基础设施层,DTO:
- 数据转换:将来自外部来源的原始数据转换为适合领域层实体的格式。
- 解耦:将领域实体与数据源结构解耦,保证数据源的变化不会直接影响领域层。
- 序列化/反序列化:与外部API和数据库通信时处理数据的序列化和反序列化。
part 'user_dto.freezed.dart';
part 'user_dto.g.dart';
@freezed
abstract class UserDTO implements _$UserDTO {
const UserDTO._();
const factory UserDTO({
@Default("") String id,
@Default("") String name,
@Default(PermissionDTO()) PermissionDTO permission,
}) = _UserDTO;
//from json and to json which is json serialization
factory UserDTO.fromJson(Map<String, dynamic> json) =>
_$UserDTOFromJson(json);
// converting UserDTO object to User entity
User toDomain() {
return User(
name: name,
id: id,
permission: permission.permissionList(),
);
}
//converting User entity to UserDTO object
factory UserDTO.fromDomain(User _) {
return UserDTO(
name: _.name,
id: _.id,
permission: PermissionDTO.fromDomain(_.permission),
);
}
}
远程数据源:
远程数据源负责与外部服务(例如 API)交互以获取和发送数据。它们处理与外界的通信并确保应用程序可以访问远程数据。在基础设施层,远程数据源:
- API 通信:管理与外部 API 的通信,包括发送请求和处理响应。
- 数据获取:从远程服务获取原始数据并将其传递给 DTO 进行转换。
@Riverpod(keepAlive: true)
AuthRemoteDataSource authRemoteDataSource(AuthRemoteDataSourceRef ref) {
return AuthRemoteDataSource(
firebaseAuth: ref.watch(firebaseAuthProvider),
firebaseFirestore: ref.watch(firebaseFirestoreProvider),
);
}
class AuthRemoteDataSource {
AuthRemoteDataSource({
required this.firebaseAuth,
required this.firebaseFirestore,
});
final FirebaseAuth firebaseAuth;
final FirebaseFirestore firebaseFirestore;
Future<UserDTO?> signIn(String email, String password) async {
final userCredential = await firebaseAuth.signInWithEmailAndPassword(
email: email, password: password);
final userDto = await getUserData(userCredential.user!.uid);
if (userDto == null) {
await userCredential.user!.delete();
}
return userDto;
}
Future<void> signOut() async => await firebaseAuth.signOut();
Future<UserDTO?> getUserData(String id) async {
final userDoc = await firebaseFirestore.collection("users").doc(id).get();
if (!userDoc.exists) return null;
return UserDTO.fromJson(userDoc.data()!);
}
}
本地数据源:
本地数据源负责访问设备本地存储的数据,例如本地数据库中的数据。它们提供了一种无需依赖网络连接即可存储和检索数据的方法。在基础设施层,本地数据源:
- 数据库操作:在本地数据库上执行CRUD(创建、读取、更新、删除)操作,确保应用程序可以通过在本地存储必要的数据来离线运行。
- 数据持久性:在应用程序会话之间维护数据持久性,允许应用程序记住和重用数据。
@Riverpod(keepAlive: true)
AuthLocalDataSource authLocalDataSource(AuthLocalDataSourceRef ref) {
return AuthLocalDataSource(
flutterSecureStorage: ref.watch(secureStorageProvider));
}
class AuthLocalDataSource {
AuthLocalDataSource({required this.flutterSecureStorage});
final FlutterSecureStorage flutterSecureStorage;
Future<void> signOut() async =>
await flutterSecureStorage.delete(key: "user");
Future<void> saveUserData(String userData) async =>
await flutterSecureStorage.write(key: 'user', value: userData);
Future<UserDTO?> getUserData() async {
final userString = await flutterSecureStorage.read(key: 'user');
if (userString == null) return Future(() => null);
return UserDTO.fromJson(jsonDecode(userString));
}
}
存储库:
存储库抽象了数据检索和操作的复杂性,为表示层提供了干净且一致的 API 进行交互。存储库处理来自远程和本地数据源的数据,确保域实体可以访问必要的数据,而无需与底层数据存储机制紧密耦合。
- 抽象:它们抽象数据源的细节,为表示层提供干净的 API 进行交互。
@Riverpod(keepAlive: true)
AuthRepository authRepository(AuthRepositoryRef ref) {
// As shown in the diagram, the remote data source and local data source
// are injected into the repository
return AuthRepository(
authRemoteDataSource: ref.watch(authRemoteDataSourceProvider),
authLocalDataSource: ref.watch(authLocalDataSourceProvider),
);
}
class AuthRepository {
AuthRepository({
required this.authRemoteDataSource,
required this.authLocalDataSource,
});
final AuthRemoteDataSource authRemoteDataSource;
final AuthLocalDataSource authLocalDataSource;
Future<User?> isSignIn() async {
final userDto = await authLocalDataSource.getUserData();
if (userDto == null) return null;
return userDto.toDomain();
}
Future<User?> signIn(String email, String password) async {
final userDto = await authRemoteDataSource.signIn(email, password);
if (userDto == null) return null;
final user = userDto.toDomain();
await authLocalDataSource.saveUserData(jsonEncode(userDto.toJson()));
return user;
}
Future<void> signOut() async {
await Future.wait([
authRemoteDataSource.signOut(),
authLocalDataSource.signOut(),
]);
}
}
综上所述,基础设施层为应用程序中的数据管理和外部通信提供了必要的支持。 DTO 促进表示层(封装实体)和数据源之间的数据传输,远程数据源处理与外部 API 的交互,本地数据源管理存储在设备上的数据。
可测试性
它旨在增强可测试性,确保应用程序的不同部分可以独立、彻底地测试。这种模块化设计促进了关注点的清晰分离,使编写单元测试、集成测试和端到端测试变得更加容易。在本文中,我们将仅关注单元测试。
表示层可测试性:
小部件:
void main() {
group('SignInScreen', () {
late AuthNotifier mockAuthNotifier;
setUp(() {
mockAuthNotifier = MockAuthNotifier();
});
testWidgets('renders SignInScreen', (tester) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
authNotifierProvider.overrideWith(() => mockAuthNotifier),
],
child: const MaterialApp(
home: SignInScreen(),
),
),
);
// Verify that the UI elements are rendered correctly
expect(find.text('some title'), findsOneWidget);
expect(find.byType(SignInForm), findsOneWidget);
expect(find.byType(LogoImage), findsOneWidget);
});
});
}
提供商:
- 模拟依赖关系:可以模拟数据源或存储库等依赖关系,从而允许单独测试提供程序。
class MockAuthRepository extends Mock implements AuthRepository {}
void main() {
const tUser = User(id: '1', name: 'John Doe');
const tUserDto = UserDTO(id: '1', name: 'John Doe');
final tException = Exception('test_exception');
const loadingState = AsyncLoading<Auth>();
const unauthenticated = AsyncData<Auth>(Auth.unauthenticated());
const authenticated = AsyncData<Auth>(Auth.authenticated(user: tUser));
late MockAuthRepository mockAuthRepository;
late MockAuthLocalDataSource mockAuthLocalDataSource;
T errorState<T>() => any(that: isA<AsyncError<Auth>>());
setUp(() {
registerFallbackValue(const AsyncLoading<Auth>());
mockAuthRepository = MockAuthRepository();
mockAuthLocalDataSource = MockAuthLocalDataSource();
});
group('sign in', () {
test('success', () async {
when(() => mockAuthRepository.isSignIn()).thenAnswer((_) async => null);
when(() => mockAuthRepository.signIn("email", "password"))
.thenAnswer((_) async => tUser);
when(() => mockAuthLocalDataSource
.saveUserData(jsonEncode(tUserDto.toJson())))
.thenAnswer((_) async => Future.value());
final container = createContainer(overrides: [
authRepositoryProvider.overrideWithValue(mockAuthRepository),
authLocalDataSourceProvider.overrideWithValue(mockAuthLocalDataSource),
]);
final listener = setUpListener(container, authNotifierProvider);
verifyOnly(listener, () => listener(null, loadingState));
final authNotifier = container.read(authNotifierProvider.notifier);
final call = authNotifier.signIn("email", "password");
await expectLater(call, completes);
verifyInOrder([
() => listener(loadingState, unauthenticated),
() => listener(unauthenticated, authenticated),
]);
verifyNoMoreInteractions(listener);
});
test('failure', () async {
when(() => mockAuthRepository.isSignIn()).thenAnswer((_) async => null);
when(() => mockAuthRepository.signIn("email", "password"))
.thenThrow(tException);
when(() => mockAuthLocalDataSource
.saveUserData(jsonEncode(tUserDto.toJson())))
.thenAnswer((_) async => Future.value());
final container = createContainer(overrides: [
authRepositoryProvider.overrideWithValue(mockAuthRepository),
authLocalDataSourceProvider.overrideWithValue(mockAuthLocalDataSource),
]);
final listener = setUpListener(container, authNotifierProvider);
verifyOnly(listener, () => listener(null, loadingState));
final authNotifier = container.read(authNotifierProvider.notifier);
final call = authNotifier.signIn("email", "password");
await expectLater(call, completes);
verifyInOrder([
() => listener(loadingState, unauthenticated),
() => listener(unauthenticated, errorState()),
]);
verifyNoMoreInteractions(listener);
});
});
}
领域层可测试性:
实体封装了业务逻辑和规则。可以通过创建不同的实例并根据业务规则验证其行为来对它们进行单元测试。
const constUser = User(
id: "1",
name: "John",
permission: ["read", "write"],
);
void main() {
group('User', () {
test('should create a User instance with default values', () {
const user = User();
expect(user.id, "");
expect(user.name, "");
expect(user.permission, moduleList);
});
test('should create a User instance with specified values', () {
expect(constUser.id, "1");
expect(constUser.name, "John");
expect(constUser.permission, ["read", "write"]);
});
test('should override specific values in a copy of User instance', () {
const originalUser = constUser;
final updatedUser = originalUser.copyWith(
name: "Jane",
);
expect(updatedUser.id, "1"); // id should remain unchanged
expect(updatedUser.name, "Jane"); // name should be updated
expect(updatedUser.permission,
["read", "write"]); // permission should remain unchanged
});
test('Equality Test', () {
const user1 = constUser;
const user2 = constUser;
const user3 = User(
id: '2', name: 'Jane', permission: ['read']);
expect(user1, equals(user2)); // user1 should be equal to user2
expect(user1 == user3, isFalse); // user1 should not be equal to user3
});
});
}
基础设施层可测试性:
DTO:
- 序列化/反序列化:可以测试 DTO 以确保它们在域层和数据源之间正确序列化和反序列化数据。
void main() {
late UserDTO userDto;
setUp(() {
userDto = const UserDTO(
id: "user123",
name: "John Doe",
);
});
group('UserDTO', () {
test('should convert UserDTO to JSON and vice versa', () {
// Convert UserDTO to JSON
final jsonMap = userDto.toJson();
// Verify the generated JSON
expect(jsonMap['id'], "user123");
expect(jsonMap['name'], "John Doe");
// Convert JSON back to UserDTO
final fromJsonUserDto = UserDTO.fromJson(jsonMap);
// Verify the conversion
expect(fromJsonUserDto.id, "user123");
expect(fromJsonUserDto.name, "John Doe");
});
});
}
远程数据源:
- API 模拟:可以通过模拟 API 响应来测试远程数据源。这允许测试与外部服务的通信逻辑,而无需进行实际的网络调用。
void main() {
group('FirestoreService', () {
late FakeFirebaseFirestore fakeFirebaseFirestore;
late MockFirebaseAuth mockFirebaseAuth;
const String collectionPath = 'collectionPath';
setUp(() {
fakeFirebaseFirestore = FakeFirebaseFirestore();
mockFirebaseAuth = MockFirebaseAuth();
});
ProviderContainer setUpContainer() {
return createContainer(overrides: [
firebaseFirestoreProvider.overrideWithValue(fakeFirebaseFirestore),
firebaseAuthProvider.overrideWithValue(mockFirebaseAuth)
]);
}
group('Sign In', () {
test('signInWithEmailAndPassword returns user on success', () async {
// Arrange
const email = 'test@example.com';
const password = 'password123';
final mockedUserCredential = MockUserCredential();
final mockedUser = MockUser();
when(() => mockFirebaseAuth.signInWithEmailAndPassword(
email: email,
password: password)).thenAnswer((_) async => mockedUserCredential);
when(() => mockedUserCredential.user).thenReturn(mockedUser);
// Act
final container = setUpContainer();
final authService = container.read(firebaseAuthProvider);
final result = await authService.signInWithEmailAndPassword(
email: email, password: password);
// Assert
expect(result, equals(mockedUserCredential));
verifyOnly(
mockFirebaseAuth,
() => mockFirebaseAuth.signInWithEmailAndPassword(
email: email, password: password));
});
});
}
本地数据源:
- 数据库模拟:可以使用内存数据库或数据库模拟库来测试本地数据源。
class MockSecureStorage extends Mock implements FlutterSecureStorage {}
void main() {
late MockSecureStorage mockSecureStorage;
const userData = '{"id": "1", "name": "John Doe"}';
setUp(() {
mockSecureStorage = MockSecureStorage();
});
ProviderContainer setUpContainer() {
return createContainer(overrides: [
secureStorageProvider.overrideWithValue(mockSecureStorage)
]);
}
group('saveUserData', () {
test('should write user data to secure storage', () async {
when(() => mockSecureStorage.write(key: 'user', value: userData))
.thenAnswer((_) async {});
final container = setUpContainer();
final authLocalDataSource = container.read(authLocalDataSourceProvider);
final result = authLocalDataSource.saveUserData(userData);
expectLater(result, completes);
verifyOnly(mockSecureStorage,
() => mockSecureStorage.write(key: 'user', value: userData));
verifyNoMoreInteractions(mockSecureStorage);
});
});
group('getUserData', () {
test('should return UserDTO when user data exists in secure storage',
() async {
when(() => mockSecureStorage.read(key: 'user'))
.thenAnswer((_) async => userData);
final container = setUpContainer();
final authLocalDataSource = container.read(authLocalDataSourceProvider);
final result = await authLocalDataSource.getUserData();
expect(result, isNotNull);
expect(result!.id, '1');
expect(result.name, 'John Doe');
verifyOnly(mockSecureStorage, () => mockSecureStorage.read(key: 'user'));
verifyNoMoreInteractions(mockSecureStorage);
});
test('should return null when user data does not exist in secure storage',
() async {
when(() => mockSecureStorage.read(key: 'user'))
.thenAnswer((_) async => null);
final container = setUpContainer();
final authLocalDataSource = container.read(authLocalDataSourceProvider);
final result = await authLocalDataSource.getUserData();
expect(result, isNull);
verifyOnly(mockSecureStorage, () => mockSecureStorage.read(key: 'user'));
verifyNoMoreInteractions(mockSecureStorage);
});
});
}
存储库:
- 模拟数据源:可以通过模拟远程和本地数据源来测试存储库。这确保了数据访问逻辑无需实际数据源即可正确工作。
class MockAuthRemoteDataSource extends Mock implements AuthRemoteDataSource {}
class MockAuthLocalDataSource extends Mock implements AuthLocalDataSource {}
void main() {
late MockAuthRemoteDataSource mockAuthRemoteDataSource;
late MockAuthLocalDataSource mockAuthLocalDataSource;
setUp(() {
mockAuthRemoteDataSource = MockAuthRemoteDataSource();
mockAuthLocalDataSource = MockAuthLocalDataSource();
});
ProviderContainer setUpContainer() {
return createContainer(
overrides: [
authRemoteDataSourceProvider
.overrideWithValue(mockAuthRemoteDataSource),
authLocalDataSourceProvider.overrideWithValue(mockAuthLocalDataSource),
],
);
}
const tUserDto = UserDTO(id: '1', name: 'John Doe');
final tUser = tUserDto.toDomain();
final tException = Exception('test_exception');
group('isSignIn', () {
test('isSignIn - returns null when local data source is null', () async {
when(() => mockAuthLocalDataSource.getUserData())
.thenAnswer((_) async => null);
final container = setUpContainer();
final authRepo = container.read(authRepositoryProvider);
final result = await authRepo.isSignIn();
expect(result, isNull);
verifyOnly(
mockAuthLocalDataSource, () => mockAuthLocalDataSource.getUserData());
});
test('isSignIn - returns User when local data source is not null',
() async {
when(() => mockAuthLocalDataSource.getUserData())
.thenAnswer((_) async => tUserDto);
final container = setUpContainer();
final authRepo = container.read(authRepositoryProvider);
final result = await authRepo.isSignIn();
expect(result, tUser);
verifyOnly(
mockAuthLocalDataSource, () => mockAuthLocalDataSource.getUserData());
});
test('signIn - returns null when remote data source returns null',
() async {
when(() => mockAuthRemoteDataSource.signIn('email', 'password'))
.thenAnswer((_) async => null);
final container = setUpContainer();
final authRepo = container.read(authRepositoryProvider);
final result = await authRepo.signIn('email', 'password');
expect(result, isNull);
verifyOnly(mockAuthRemoteDataSource,
() => mockAuthRemoteDataSource.signIn('email', 'password'));
});
});
group(
'signIn',
() {
test(
'should return remote data '
'when the call to remote data source is successful',
() async {
// GIVEN
when(() => mockAuthRemoteDataSource.signIn("email", "password"))
.thenAnswer((_) async => tUserDto);
when(() => mockAuthLocalDataSource
.saveUserData(jsonEncode(tUserDto.toJson())))
.thenAnswer((_) async => Future.value());
final container = setUpContainer();
// WHEN
final authRepo = container.read(authRepositoryProvider);
final result = await authRepo.signIn("email", "password");
expect(result, tUser);
// THEN
verifyOnly(
mockAuthRemoteDataSource,
() => mockAuthRemoteDataSource.signIn("email", "password"),
);
},
);
test(
'should throw same Exception '
'when the call to remote data source is unsuccessful',
() async {
// GIVEN
when(() => mockAuthRemoteDataSource.signIn("email", "password"))
.thenThrow(tException);
final container = setUpContainer();
// WHEN
final authRepo = container.read(authRepositoryProvider);
final call = authRepo.signIn("email", "password");
// THEN
await expectLater(call, throwsA(tException));
verifyOnly(
mockAuthRemoteDataSource,
() => mockAuthRemoteDataSource.signIn("email", "password"),
);
},
);
test('signOut - calls signOut on both remote and local data sources',
() async {
// GIVEN
when(() => mockAuthRemoteDataSource.signOut()).thenAnswer((_) async {});
when(() => mockAuthLocalDataSource.signOut()).thenAnswer((_) async {});
final container = setUpContainer();
// WHEN
final authRepo = container.read(authRepositoryProvider);
final call = authRepo.signOut();
// THEN
await expectLater(call, completes);
verifyOnly(
mockAuthRemoteDataSource, () => mockAuthRemoteDataSource.signOut());
verifyOnly(
mockAuthLocalDataSource, () => mockAuthLocalDataSource.signOut());
});
},
);
}
结论
虽然完全实现 DDD 可能很复杂,但结合其原则为构建健壮的应用程序提供了坚实的基础。通过将代码库构建为定义良好的层并注重可测试性,开发人员可以创建不仅功能齐全、可扩展且易于维护的应用程序。
采用这种架构方法可以确保您的 Flutter 应用程序保持干净、高效,并准备好随着不断变化的业务需求而发展。
- 原文作者:知识铺
- 原文链接:https://index.zshipu.com/geek001/post/20240710/Flutter-%E5%BA%94%E7%94%A8%E7%A8%8B%E5%BA%8F%E6%9E%B6%E6%9E%84%E9%BC%93%E8%88%9E%E4%BA%BA%E5%BF%83%E7%9A%84%E9%A2%86%E5%9F%9F%E9%A9%B1%E5%8A%A8%E8%AE%BE%E8%AE%A1--%E7%9F%A5%E8%AF%86%E9%93%BA/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。
- 免责声明:本页面内容均来源于站内编辑发布,部分信息来源互联网,并不意味着本站赞同其观点或者证实其内容的真实性,如涉及版权等问题,请立即联系客服进行更改或删除,保证您的合法权益。转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达。也可以邮件至 sblig@126.com