Project Icon

flutter_clean_architecture

Flutter清洁架构实现的开源解决方案

flutter_clean_architecture是一个开源Flutter包,简化了Uncle Bob清洁架构的实现。该包提供了四个主要模块:App、Domain、Data和Device,并通过依赖规则确保模块间的独立性。其设计理念强调关注点分离和可扩展性,有助于提升Flutter项目的代码质量和可维护性。

flutter_clean_architecture 包

CI

概述

这是一个Flutter包,使得在Flutter中实现Uncle Bob的清洁架构变得简单直观。该包提供了根据清洁架构设计并针对Flutter进行调优的基本类。

安装

1. 添加依赖

在你的包的pubspec.yaml文件中添加以下内容:

dependencies:
  flutter_clean_architecture: ^6.0.1

2. 安装

你可以从命令行安装包:

使用Flutter:

$ flutter packages get

或者,你的编辑器可能支持flutter packages get。查看你的编辑器文档了解更多。

3. 导入

现在在你的Dart代码中,你可以使用:

import 'package:flutter_clean_architecture/flutter_clean_architecture.dart';

Flutter清洁架构入门

介绍

这是基于Uncle Bob的书籍和博客的架构。它结合了洋葱架构和其他架构的概念。该架构的主要关注点是关注点分离和可扩展性。它由四个主要模块组成:AppDomainDataDevice

依赖规则

源代码依赖只指向内部。这意味着内部模块既不知道也不依赖于外部模块。但是,外部模块既知道也依赖于内部模块。外部模块代表了业务规则和策略(内部模块)运作的机制。越往内部移动,抽象程度越高。越往外部移动,具体实现越多。内部模块不知道外部模块中存在的任何类、函数、名称、库等。它们只代表规则,完全独立于实现。

层次

Domain

Domain模块定义了应用程序的业务逻辑。它是一个独立于开发平台的模块,即它纯粹用编程语言编写,不包含平台的任何元素。在Flutter的情况下,Domain将纯粹用Dart编写,不包含任何Flutter元素。这是因为Domain应该只关注应用程序的业务逻辑,而不是实现细节。这也允许在平台之间轻松迁移,如果出现任何问题的话。

Domain的内容

Domain由几个部分组成。

  • 实体
    • 企业范围的业务规则
    • 由可以包含方法的类组成
    • 应用程序的业务对象
    • 在整个应用程序中使用
    • 当应用程序中的某些内容发生变化时,最不可能改变
  • 用例
    • 特定应用程序的业务规则
    • 封装应用程序的所有用例
    • 编排整个应用程序的数据流
    • 不应受到任何UI更改的影响
    • 如果应用程序的功能和流程发生变化,可能会改变
  • 仓库
    • 定义外层预期功能的抽象类
    • 不了解外层,只定义预期功能
      • 例如,Login用例期望有login功能的Repository
    • 从外层传递给用例

Domain代表最内层。因此,它是架构中最抽象的层。

App

AppDomain外部的层。App跨越层边界与Domain通信。然而,依赖规则从未被违反。使用多态性App通过继承类与Domain通信:实现或扩展Domain层中存在的Repositories的类。由于使用了多态性,传递给DomainRepositories仍然遵守依赖规则,因为就Domain而言,它们是抽象的。实现隐藏在多态性之后。

App的内容

由于App是应用程序的表现层,它是最依赖框架的层,因为它包含UI和UI的事件处理程序。对于应用程序中的每个页面,App至少定义3个类:ControllerPresenterView

  • 视图
    • 只代表页面的UI。视图构建页面的UI,设置样式,并依赖控制器处理其事件。视图 包含 控制器
    • 在Flutter的情况下
      • 视图由2个类组成
        • 一个继承自View,是代表视图的根Widget
        • 一个继承自ViewState,带有另一个类及其控制器的模板特化。
      • ViewState包含view getter,这实际上是UI实现
      • StatefulWidget按照Flutter的规定包含State
      • StatefulWidget只用于从其他页面传递参数给State,如标题等。它只实例化State对象(ViewState)并通过其消费者提供所需的控制器
      • StatefulWidget 包含 State对象(ViewState),而ViewState 包含 控制器
      • 总之,StatefulWidgetState都由页面的ViewViewState表示。
      • ViewState类维护一个GlobalKey,可用作其scaffold的key。如果使用,控制器可以通过getState()轻松访问它以显示snackbar和其他对话框。这很有用但是可选的。
  • 控制器
    • 每个ViewState 包含 控制器控制器提供ViewState所需的成员数据,即动态数据。控制器还实现ViewState小部件的事件处理程序,但无法访问小部件本身。ViewState使用控制器,而不是相反。当ViewState调用控制器的处理程序时,可以调用refreshUI()来更新视图。
    • 每个控制器都继承自Controller抽象类,该类实现了WidgetsBindingObserver。每个控制器类负责处理视图的生命周期事件,可以重写:
      • void onInActive()
      • void onPaused()
      • void onResumed()
      • void onDetached()
      • void onDisposed()
      • void onReassembled()
      • void onDidChangeDependencies()
      • void onInitState()
      • 等..
    • 此外,每个控制器 必须 实现 initListeners() 以初始化展示器的监听器,以保持一致性。
    • 控制器 包含 展示器控制器仓库传递给展示器,后者将与用例通信。控制器将指定展示器应为所有成功和错误事件调用哪些监听器,如前所述。只有控制器被允许从最外层的数据设备模块获取仓库的实例。
    • 控制器可以访问ViewState,并可以通过refreshUI()刷新受控小部件
  • 展示器
    • 每个控制器 包含 展示器展示器用例通信,如应用层开始时所述。展示器将有作为函数的成员,这些函数由控制器可选设置,并在用例返回数据、完成或出错时被调用(如果设置了的话)。
    • 展示器由两个类组成
      • 展示器 例如 LoginPresenter
        • 包含由控制器设置的事件处理程序
        • 包含要使用的用例
        • 使用Observer<T>类和适当的参数初始化并执行用例。例如,在LoginPresenter的情况下使用usernamepassword
      • 一个实现Observer<T>的类
        • 有对展示器类的引用。理想情况下,这应该是一个内部类,但Dart尚不支持内部类。
        • 实现3个函数
          • onNext(T)
          • onComplete()
          • onError()
        • 这3个方法代表用例的所有可能输出
          • 如果用例返回一个对象,它将被传递给onNext(T)
          • 如果出错,它将调用onError(e)
          • 一旦完成,它将调用onComplete()
        • 这些方法然后会调用展示器中由控制器设置的相应方法。这样,事件就传递给了控制器,后者可以操作数据并更新ViewState
  • 额外
    • 实用工具类(任何常用函数,如时间戳获取器等)
    • 常量类(方便使用的const字符串)
    • 导航器(如果需要)

数据

代表应用程序的数据层。数据模块是最外层的一部分,负责数据检索。这可以是对服务器的API调用、本地数据库,甚至两者都有。

数据内容
  • 仓库
    • 每个仓库 应该 实现 领域 层的Repository
    • 使用多态性,这些来自数据层的仓库可以跨层边界传递,从视图开始,通过控制器展示器一直到用例
    • 从数据库或其他方法检索数据。
    • 负责任何API调用和高级数据操作,如
      • 在数据库中注册用户
      • 上传数据
      • 下载数据
      • 处理本地存储
      • 调用API
  • 模型(不是必须的,取决于应用程序)
    • 实体的扩展,增加了可能依赖平台的额外成员。例如,对于本地数据库,这可以表现为本地数据库中的isDeletedisDirty条目。这些条目不能出现在实体中,因为那会违反依赖规则,因为领域不应该了解实现细节。
    • 在我们的应用程序中,数据层的模型将不是必需的,因为我们没有本地数据库。因此,我们不太可能需要在实体中添加依赖平台的额外条目。
  • 映射器
    • 实体对象映射到模型,反之亦然。
    • 静态类,带有静态方法,接收实体模型并返回另一个。
    • 只有在存在模型时才需要
  • 额外
    • 如果需要,可以有实用工具
    • 如果需要,可以有常量

设备

作为最外层的一部分,设备直接与平台(即Android和iOS)通信。设备负责本机功能,如GPS和平台本身存在的其他功能,如文件系统。设备调用所有本机API。

数据内容
  • 设备
    • 类似于数据中的仓库设备是与平台中特定功能通信的类。
    • 以与仓库相同的方式通过层传递:使用应用层和领域层之间的多态性。这意味着控制器将其传递给展示器,然后展示器多态地将其传递给用例,后者将其作为抽象类接收。
  • 额外
    • 如果需要,可以有实用工具
    • 如果需要,可以有常量

用法

文件夹结构

lib/
    app/                          <--- 应用层
        pages/                        <-- 页面或屏幕
          login/                        <-- 应用中的某个页面
            login_controller.dart         <-- 登录控制器继承自`Controller`
            login_presenter.dart          <-- 登录展示器继承自`Presenter`
            login_view.dart               <-- 登录视图,2个类分别继承`View`和`ViewState`
        widgets/                      <-- 自定义小部件
        utils/                        <-- 实用函数/类/常量
        navigator.dart                <-- 可选的应用导航器
    data/                         <--- 数据层
        repositories/                 <-- 仓库(检索数据,heavy处理等)
          data_auth_repo.dart           <-- 示例仓库:处理所有认证
        helpers/                      <-- 任何辅助类,如http辅助类
        constants.dart                <-- 常量,如API密钥、路由、URL等
    device/                       <--- 设备层
        repositories/                 <--- 与平台通信的仓库,如GPS
        utils/                        <--- 任何实用类/函数
    domain/                       <--- 领域层(业务和企业)纯DART
        entities/                   <--- 企业实体(应用的核心类)
          user.dart                   <-- 示例实体
          manager.dart                <-- 示例实体
        usecases/                   <--- 业务流程,如登录、登出、获取用户等
          login_usecase.dart          <-- 示例用例继承自`UseCase`或`CompletableUseCase`
        repositories/               <--- 定义数据和设备层功能的抽象类
    main.dart                     <--- 入口点

示例代码

这里查看一个小示例,在这里查看构建的完整应用程序。

View 和 ControlledWidgetBuilder

import 'package:flutter_clean_architecture/flutter_clean_architecture.dart';
class CounterPage extends View {
    @override
     // 可以在此处注入依赖
     State<StatefulWidget> createState() => CounterState();
}

class CounterState extends ViewState<CounterPage, CounterController> {
     CounterState() : super(CounterController());

     @override
     Widget get view => MaterialApp(
        title: 'Flutter Demo',
        home: Scaffold(
        key: globalKey, // 使用 `View` 内置的全局键用于 scaffold 或任何其他
                        // 小部件为控制器提供了通过 getContext()、getState()、getStateKey() 访问它们的方法
        body: Column(
          children: <Widget>[
            Center(
              // 显示按钮被点击的次数
              child: ControlledWidgetBuilder<CounterController>(
                builder: (context, controller) {
                  return Text(controller.counter.toString());
                }
              ),
            ),
            // 你可以在控制器内部手动刷新
            // 使用 refreshUI()
            ControlledWidgetBuilder<CounterController>(
                builder: (context, controller) {
                  return MaterialButton(onPressed: controller.increment);
                }
              ),
          ],
        ),
      ),
    );
}
响应式视图状态

为了处理 Flutter Web 上的屏幕,你可以利用响应式视图状态, 它抽象了主要的 Web 应用程序断点(桌面、平板和移动),以简化使用 flutter_clean_architecture 进行 Web 开发

例如:

import 'package:flutter_clean_architecture/flutter_clean_architecture.dart';
class CounterPage extends View {
    @override
     // 可以在此处注入依赖
     State<StatefulWidget> createState() => CounterState();
}

class CounterState extends ResponsiveViewState<CounterPage, CounterController> {
     CounterState() : super(CounterController());

     Widget AppScaffold({Widget child}) {
         return MaterialApp(
              title: 'Flutter Demo',
           home: Scaffold(
             key: globalKey, // 使用 `View` 内置的全局键用于 scaffold 或任何其他
                             // 小部件为控制器提供了通过 getContext()、getState()、getStateKey() 访问它们的方法
             body: child
           ),
         );
     }
 
     @override
     ViewBuilder get mobileView => AppScaffold(
         child: Column(
             children: <Widget>[
               // 你可以在控制器内部手动刷新
               // 使用 refreshUI()
               ControlledWidgetBuilder<CounterController>(
                 builder: (context, controller) {
                   return Text('移动视图上的计数器 ${controller.counter.toString()}');
                 }
               ),
             ],
           )
      );

     @override
     ViewBuilder get tabletBuilder => AppScaffold(
       child: Column(
           children: <Widget>[
             // 你可以在控制器内部手动刷新
             // 使用 refreshUI()
             ControlledWidgetBuilder<CounterController>(
               builder: (context, controller) {
                 return Text('平板视图上的计数器 ${controller.counter.toString()}');
               }
             ),
           ],
         )
     );

     @override
     ViewBuilder get desktopBuilder => AppScaffold(
        child: Row(
            children: <Widget>[
              // 你可以在控制器内部手动刷新
              // 使用 refreshUI()
              ControlledWidgetBuilder<CounterController>(
                builder: (context, controller) {
                  return Text('桌面视图上的计数器 ${controller.counter.toString()}');
                }
              ),
            ],
          )
      );
}
具有共同控制器的小部件

如果多个小部件需要使用某个 Page 的相同 Controller, 可以通过 FlutterCleanArchitecture.getController<HomeController>(context) 在该页面的子小部件中检索 Controller

例如:


import '../pages/home/home_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_clean_architecture/flutter_clean_architecture.dart';

class HomePageButton extends StatelessWidget {
  final String text;
  HomePageButton({@required this.text});

  @override
  Widget build(BuildContext context) {
    // 使用通用控制器,假设 HomePageButton 始终是 Home 的子级
    HomeController controller =
        FlutterCleanArchitecture.getController<HomeController>(context);
    return GestureDetector(
      onTap: controller.buttonPressed,
      child: Container(
        height: 50.0,
        alignment: FractionalOffset.center,
        decoration: BoxDecoration(
          color: Color.fromRGBO(230, 38, 39, 1.0),
          borderRadius: BorderRadius.circular(25.0),
        ),
        child: Text(
          text,
          style: const TextStyle(
              color: Colors.white,
              fontSize: 20.0,
              fontWeight: FontWeight.w300,
              letterSpacing: 0.4),
        ),
      ),
    );
  }
}

控制器

import 'package:flutter_clean_architecture/flutter_clean_architecture.dart';

class CounterController extends Controller {
  int counter;
  final LoginPresenter presenter;
  CounterController() : counter = 0, presenter = LoginPresenter(), super();

  void increment() {
    counter++;
  }

  /// 显示一个 snackbar
  void showSnackBar() {
    ScaffoldState scaffoldState = getState(); // 获取状态,在这种情况下是 scaffold
    scaffoldState.showSnackBar(SnackBar(content: Text('你好')));
  }

  @override
  void initListeners() {
    // 在此初始化 presenter 监听器
    // 这些将在用例执行后成功、失败或数据检索时被调用
     presenter.loginOnComplete = () => print('登录成功');
     presenter.loginOnError = (e) => print(e);
     presenter.loginOnNext = () => print("onNext");
  }

  void login() {
      // 在此传递适当的凭证
      // 假设你有文本字段来检索它们等
      presenter.login();
  }
}

Presenter

import 'package:flutter_clean_architecture/flutter_clean_architecture.dart';

class LoginPresenter() {

  Function loginOnComplete; // 或者 `void loginOnComplete();`
  Function loginOnError;
  Function loginOnNext; // 在登录 presenter 的情况下不需要

  final LoginUseCase loginUseCase;
  // 从控制器进行依赖注入
  LoginPresenter(authenticationRepo): loginUseCase = LoginUseCase(authenticationRepo);

  /// 控制器调用的登录函数
  void login(String email, String password) {
    loginUseCase.execute(_LoginUseCaseObserver(this), LoginUseCaseParams(email, password));
  }

   /// 处理 [LoginUseCase] 并取消订阅
   @override
   void dispose() {
     _loginUseCase.dispose();
   }
}

/// 用于观察 [LoginUseCase] 的 `Stream` 的 [Observer]
class _LoginUseCaseObserver implements Observer<void> {

  // 上面的 presenter
  // 这不是最优的,但由于 Dart 的限制,这是一个变通方法。Dart 不
  // 支持内部类或匿名类。
  final LoginPresenter loginPresenter;

  _LoginUseCaseObserver(this.loginPresenter);

  /// 如果 `Stream` 发出一个值则实现
  // 在这种情况下,不必要
  void onNext(_) {}

  /// 登录成功,在 [LoginController] 中触发事件
  void onComplete() {
    // 任何清理或准备工作都在这里进行
    assert(loginPresenter.loginOnComplete != null);
    loginPresenter.loginOnComplete();

  }

  /// 登录失败,在 [LoginController] 中触发事件
  void onError(e) {
    // 任何清理或准备工作都在这里进行
    assert(loginPresenter.loginOnError != null);
    loginPresenter.loginOnError(e);
  }
}

UseCase

import 'package:flutter_clean_architecture/flutter_clean_architecture.dart';

// 在这种情况下,不需要参数。因此,void。否则,更改为适当的类型。
class LoginUseCase extends CompletableUseCase<LoginUseCaseParams> {
  final AuthenticationRepository _authenticationRepository; // 一些需要注入的依赖
                                          // 功能隐藏在这个
                                          // 在 Domain 模块中定义的抽象类后面
                                          // 它应该在 Data 或 Device 模块中实现
                                          // 并以多态方式传递。

  LoginUseCase(this._authenticationRepository);

  @override
  // 由于参数类型是 void,`_` 忽略参数。根据模板中使用的类型进行更改。
  Future<Stream<void>> buildUseCaseStream(params) async {
    final StreamController controller = StreamController();
    try {
        // 假设你在这里传递凭证
      await _authenticationRepository.authenticate(email: params.email, password: params.password);
      logger.finest('LoginUseCase 成功。');
      // 触发 onComplete
      controller.close();
    } catch (e) {
      print(e);
      logger.severe('LoginUseCase 失败。');
      // 触发 .onError
      controller.addError(e);
    }
    return controller.stream;
  }
}
```dart
class LoginUseCaseParams {
    final String email;
    final String password;
    LoginUseCaseParams(this.email, this.password);
}
后台 UseCase

可以使用 BackgroundUseCase 类在单独的 isolate 上运行用例。 由于 isolate 的限制,实现这种用例与常规用例略有不同。 要创建 BackgroundUseCase,只需扩展该类并重写 buildUseCaseTask 方法。 此方法应返回 UseCaseTask,它只是一个返回类型为 void 且接受 BackgroundUseCaseParameters 参数的函数。 此方法应为静态方法,并将包含您希望在单独的 isolate 上运行的所有代码。此方法应使用 BackgroundUseCaseParameters 中提供的 port 与主 isolate 通信,如下所示。这个例子是执行矩阵乘法的 BackgroundUseCase


class MatMulUseCase extends BackgroundUseCase<List<List<double>>, MatMulUseCaseParams> {

  // 必须重写
  @override
  buildUseCaseTask() {
    return matmul;  // 返回包含要在 isolate 上运行的代码的静态方法
  }

  /// 此方法将在单独的 isolate 上执行。[params] 包含所有数据和所需的 sendPort
  static void matmul(BackgroundUseCaseParams params) async {
    MatMulUseCaseParams matMulParams = params.params as MatMulUseCaseParams;
    List<List<double>> result = List<List<double>>.generate(
        10, (i) => List<double>.generate(10, (j) => 0));

    for (int i = 0; i < matMulParams.mat1.length; i++) {
      for (int j = 0; j < matMulParams.mat1.length; j++) {
        for (int k = 0; k < matMulParams.mat1.length; k++) {
          result[i][j] += matMulParams.mat1[i][k] * matMulParams.mat2[k][j];
        }
      }
    }
    // 将结果发送回主 isolate
    // 这将转发给观察者监听器
    params.port.send(BackgroundUseCaseMessage(data: result));

  }
}

与常规 [UseCase] 一样,建议为任何 [BackgroundUseCase] 使用参数类。 与上面示例对应的示例如下

class MatMulUseCaseParams {
 List<List<double>> mat1;
 List<List<double>> mat2;
 MatMulUseCaseParams(this.mat1, this.mat2);
 MatMulUseCaseParams.random() {
   var size = 10;
   mat1 = List<List<double>>.generate(size,
       (i) => List<double>.generate(size, (j) => i.toDouble() * size + j));

   mat2 = List<List<double>>.generate(size,
       (i) => List<double>.generate(size, (j) => i.toDouble() * size + j));
 }
}

领域中的仓库


abstract class AuthenticationRepository {
  Future<void> register(
      {@required String firstName,
      @required String lastName,
      @required String email,
      @required String password});

  /// 使用 [username] 和 [password] 对用户进行身份验证
  Future<void> authenticate(
      {@required String email, @required String password});

  /// 返回 [User] 是否已通过身份验证。
  Future<bool> isAuthenticated();

  /// 返回当前已验证的 [User]。
  Future<User> getCurrentUser();

  /// 重置 [User] 的密码
  Future<void> forgotPassword(String email);

  /// 注销 [User]
  Future<void> logout();
}

此仓库应在数据层中实现


class DataAuthenticationRepository extends AuthenticationRepository {
  // 单例
  static DataAuthenticationRepository _instance = DataAuthenticationRepository._internal();
  DataAuthenticationRepository._internal();
  factory DataAuthenticationRepository() => _instance;

    @override
  Future<void> register(
      {@required String firstName,
      @required String lastName,
      @required String email,
      @required String password}) {
          // 待实现
      }

  /// 使用 [username] 和 [password] 对用户进行身份验证
  @override
  Future<void> authenticate(
      {@required String email, @required String password}) {
          // 待实现
      }

  /// 返回 [User] 是否已通过身份验证。
  @override
  Future<bool> isAuthenticated() {
      // 待实现
  }

  /// 返回当前已验证的 [User]。
  @override
  Future<User> getCurrentUser() {
      // 待实现
  }

  /// 重置 [User] 的密码
  @override
  Future<void> forgotPassword(String email) {
      // 待实现
  }

  /// 注销 [User]
  @override
  Future<void> logout() {
      // 待实现
  }
}

如果仓库与平台相关,请在设备层中实现。

实体

领域层中定义。


class User {
    final String name;
    final String email;
    final String uid;
    User(this.name, this.email, this.uid);
}

此处查看一个小示例,在此处查看构建的完整应用程序。

作者

Shady Boukhary Rafael Monteiro

项目侧边栏1项目侧边栏2
推荐项目
Project Cover

豆包MarsCode

豆包 MarsCode 是一款革命性的编程助手,通过AI技术提供代码补全、单测生成、代码解释和智能问答等功能,支持100+编程语言,与主流编辑器无缝集成,显著提升开发效率和代码质量。

Project Cover

AI写歌

Suno AI是一个革命性的AI音乐创作平台,能在短短30秒内帮助用户创作出一首完整的歌曲。无论是寻找创作灵感还是需要快速制作音乐,Suno AI都是音乐爱好者和专业人士的理想选择。

Project Cover

有言AI

有言平台提供一站式AIGC视频创作解决方案,通过智能技术简化视频制作流程。无论是企业宣传还是个人分享,有言都能帮助用户快速、轻松地制作出专业级别的视频内容。

Project Cover

Kimi

Kimi AI助手提供多语言对话支持,能够阅读和理解用户上传的文件内容,解析网页信息,并结合搜索结果为用户提供详尽的答案。无论是日常咨询还是专业问题,Kimi都能以友好、专业的方式提供帮助。

Project Cover

阿里绘蛙

绘蛙是阿里巴巴集团推出的革命性AI电商营销平台。利用尖端人工智能技术,为商家提供一键生成商品图和营销文案的服务,显著提升内容创作效率和营销效果。适用于淘宝、天猫等电商平台,让商品第一时间被种草。

Project Cover

吐司

探索Tensor.Art平台的独特AI模型,免费访问各种图像生成与AI训练工具,从Stable Diffusion等基础模型开始,轻松实现创新图像生成。体验前沿的AI技术,推动个人和企业的创新发展。

Project Cover

SubCat字幕猫

SubCat字幕猫APP是一款创新的视频播放器,它将改变您观看视频的方式!SubCat结合了先进的人工智能技术,为您提供即时视频字幕翻译,无论是本地视频还是网络流媒体,让您轻松享受各种语言的内容。

Project Cover

美间AI

美间AI创意设计平台,利用前沿AI技术,为设计师和营销人员提供一站式设计解决方案。从智能海报到3D效果图,再到文案生成,美间让创意设计更简单、更高效。

Project Cover

AIWritePaper论文写作

AIWritePaper论文写作是一站式AI论文写作辅助工具,简化了选题、文献检索至论文撰写的整个过程。通过简单设定,平台可快速生成高质量论文大纲和全文,配合图表、参考文献等一应俱全,同时提供开题报告和答辩PPT等增值服务,保障数据安全,有效提升写作效率和论文质量。

投诉举报邮箱: service@vectorlightyear.com
@2024 懂AI·鲁ICP备2024100362号-6·鲁公网安备37021002001498号