In the scope of this article, I assume that you already know what Clean Architecture is, and why should we use it.
./ Flutter Clean Architecture
As you can see, there are three main layers: Presentation, Domain, and Data.
Role and description of each layer:
I. Presentation
There is no business logic processing on this layer, hence it is only used to show UI and handle events.
1. Widget (Page)
- Notify BloC of events such as screen display and user touch events, and Listen to the events emitted from BloC as well.
2. BloC
- Receive events from View and execute UseCase according to the event if necessary.
- Emit the data received from UseCase to View.
- BloC doesn’t know about View at all.
II. Domain
This layer is responsible for business logic.
1. UseCase
- Describes the logic processing required for the UseCase.
- Work directly to Repository.
2. Translator
- Convert the model into the entity in order to request to server-side, or convert the entity into the model to use for Presentation layer.
3. Model
- A model will not depend on the data that acquire from server-side.
- A model is used for the Presentation layer.
III. Data
This layer is responsible for communicating with server-side and data management logic.
1. DataSource
- Describes the process to acquire and update the data.
- This is the place decided whether to get the data from the server-side or use the data in the DB or cache.
2. Entity
- An entity will depend on the data that acquire from server-side.
- An entity is not used for the Presentation layer.
3. Repository
- The bridge between Data layer and Domain layer.
- We will use Translator in Repository in order to convert data.
./ Practice
For a better understanding, I’d like to demonstrate a flow that receives an action from Widget, processes it, requests/receives data from the server-side, and finally displays the data to Widget.
Source-code: https://github.com/dubydu/fluttourII
1. Data Layer
- Create an Entity.
@JsonSerializable(explicitToJson: true) | |
class FluttourResponse { | |
FluttourResponse({ | |
this.flutterVersion, | |
this.dartVersion, | |
this.devToolsVersion, | |
this.cocoapodsVersion, | |
this.frameworkRevision, | |
}); | |
final String? flutterVersion; | |
final String? dartVersion; | |
final String? devToolsVersion; | |
final String? cocoapodsVersion; | |
final String? frameworkRevision; | |
factory FluttourResponse.fromJson(Map<String, dynamic> json) => | |
_$FluttourResponseFromJson(json); | |
Map<String, dynamic> toJson() => _$FluttourResponseToJson(this); | |
} |
- Inside api_client_type.dart, define an HTTP request like this:
@retrofit.GET('/fluttour-doctor') | |
Future<FluttourResponse> getFluttourDoctor(); |
- Then, open your terminal, and run this command:
make gen
, the retrofit will take the rest responsibility. You also can custom the command line by editing this Makefile.
- Define DataSource
abstract class HomeDataSourceType { | |
Future<FluttourResponse> getFluttourDoctor(); | |
} |
class HomeDataSource implements HomeDataSourceType { | |
HomeDataSource({required this.apiClient}); | |
final APIClientType apiClient; | |
@override | |
Future<FluttourResponse> getFluttourDoctor() async { | |
return await apiClient.getFluttourDoctor(); | |
} | |
} |
- Implement Repository
abstract class HomeRepositoryType { | |
Future<Either<Failure, FluttourResponse>> getFluttourDoctor(); | |
} |
class HomeRepository with ConnectivityMixin implements HomeRepositoryType { | |
HomeRepository({required this.dataSource}); | |
final HomeDataSourceType dataSource; | |
@override | |
Future<Either<Failure, FluttourResponse>> getFluttourDoctor() async { | |
if (await isInConnection()) { | |
try { | |
final response = await dataSource.getFluttourDoctor(); | |
return Right(response); | |
} catch (e) { | |
return Left(Failure(e.toString())); | |
} | |
} | |
return const Left(NoConnection()); | |
} | |
} |
2. Domain Layer
- Create Model
class Fluttour extends Equatable { | |
const Fluttour({ | |
required this.flutterVersion, | |
required this.dartVersion, | |
required this.devToolsVersion, | |
required this.cocoapodsVersion | |
}); | |
final String? flutterVersion; | |
final String? dartVersion; | |
final String? devToolsVersion; | |
final String? cocoapodsVersion; | |
@override | |
List<Object?> get props => [ | |
flutterVersion, | |
dartVersion, | |
devToolsVersion, | |
cocoapodsVersion | |
]; | |
Map<String, dynamic> toJson() => { | |
"flutterVersion": flutterVersion, | |
"dartVersion": dartVersion, | |
"devToolsVersion": devToolsVersion, | |
"cocoapodsVersion": cocoapodsVersion, | |
}; | |
} |
- Define Translator
class HomeTranslator { | |
/// To Model | |
static Fluttour toModel({required FluttourResponse response}) { | |
return Fluttour( | |
flutterVersion: response.flutterVersion, | |
dartVersion: response.dartVersion, | |
devToolsVersion: response.devToolsVersion, | |
cocoapodsVersion: response.cocoapodsVersion, | |
); | |
} | |
} |
- Implement UseCase
abstract class HomeUseCaseType { | |
Future<Either<Failure, Fluttour>> getFluttourDoctor(); | |
} |
class HomeUseCase implements HomeUseCaseType { | |
HomeUseCase({required this.repository}); | |
final HomeRepositoryType repository; | |
@override | |
Future<Either<Failure, Fluttour>> getFluttourDoctor() async { | |
final result = await repository.getFluttourDoctor(); | |
return result.map((response) => HomeTranslator.toModel(response: response)); | |
} | |
} |
3. Presentation Layer
- Define BloC
class HomeBloc extends Cubit<HomeState> { | |
HomeBloc({ | |
required this.useCase | |
}) : super(const HomeInitialState()); | |
final HomeUseCaseType useCase; | |
Future<void> getFluttourDoctor() async { | |
final response = await useCase.getFluttourDoctor(); | |
response.fold((error) { | |
log('=============== $error'); | |
}, (response) { | |
emit(state.copyWith(fluttour: response)); | |
}); | |
} | |
} | |
class HomeState extends Equatable { | |
const HomeState({required this.fluttour}); | |
final Fluttour? fluttour; | |
HomeState copyWith({ | |
final Fluttour? fluttour, | |
}) { | |
return HomeState( | |
fluttour: fluttour ?? this.fluttour, | |
); | |
} | |
@override | |
List<Object?> get props => [fluttour]; | |
} | |
class HomeInitialState extends HomeState { | |
const HomeInitialState(): super(fluttour: null); | |
} |
- Implement Widget
class HomePage extends StatefulWidget { | |
const HomePage({Key? key}) : super(key: key); | |
@override | |
State<HomePage> createState() => _HomePageState(); | |
} | |
class _HomePageState extends State<HomePage> | |
with ResponsiveMixin, AfterLayoutMixin, RouteAware { | |
late HomeBloc _homeBloc; | |
@override | |
Future<void> afterFirstLayout(BuildContext context) async { | |
// Init home bloc | |
_homeBloc = BlocProvider.of(context, listen: false); | |
// Call fluttour doctor api | |
await _getFluttourDoctor(); | |
} | |
Future<void> _getFluttourDoctor() async { | |
await _homeBloc.getFluttourDoctor(); | |
} | |
@override | |
void didChangeDependencies() { | |
super.didChangeDependencies(); | |
// Subscribe routeAware | |
global.navigationObserver.subscribe(this, ModalRoute.of(context)!); | |
} | |
@override | |
void didPopNext() async { | |
// Call fluttour doctor api | |
await _getFluttourDoctor(); | |
super.didPopNext(); | |
} | |
@override | |
void dispose() { | |
// Unsubscribe routeAware | |
global.navigationObserver.unsubscribe(this); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
initResponsive(context); | |
return BaseMaterialPage( | |
child: SafeArea( | |
child: BlocBuilder<HomeBloc, HomeState>( | |
builder: (context, state) { | |
if (state.fluttour != null) { | |
return Column( | |
mainAxisAlignment: MainAxisAlignment.center, | |
children: <Widget>[ ], | |
); | |
} else { | |
return SizedBox( | |
width: 24.sp, | |
height: 24.sp, | |
child: const CupertinoActivityIndicator(), | |
); | |
} | |
}, | |
), | |
) | |
); | |
} | |
} |
4. DI
Let’s make our code becomes more modular. 😎
- Create config_module
mixin ConfigModule { | |
/// App config | |
AppConfigType get appConfig { | |
return AppConfig.shared; | |
} | |
/// App Secure config | |
AppSecureConfigType get appSecureConfig { | |
return AppSecureConfig(); | |
} | |
} |
- Create client_module
mixin ClientModule on ConfigModule { | |
/// API Client | |
APIClientType get apiClient { | |
return APIClient.apiClient( | |
baseDomain: appConfig.baseDomain, | |
authToken: appSecureConfig.authToken | |
); | |
} | |
} |
- Create datasource_module
mixin DatasourceModule on ClientModule { | |
/// HomeDataSource | |
HomeDataSourceType get homeDataSource { | |
return HomeDataSource(apiClient: apiClient); | |
} | |
} |
- Create repository_module
mixin RepositoryModule on DatasourceModule { | |
/// HomeRepository | |
HomeRepositoryType get homeRepository { | |
return HomeRepository(dataSource: homeDataSource); | |
} | |
} |
- Create usecase_module
mixin UseCaseModule on RepositoryModule { | |
/// HomeUseCase | |
HomeUseCase get homeUseCase { | |
return HomeUseCase(repository: homeRepository); | |
} | |
} |
- Inject UseCase to BloC in my_app.dart by conforming to all of the modules.
class MyAppState extends State<MyApp> | |
with ConfigModule, | |
ClientModule, | |
DatasourceModule, | |
RepositoryModule, | |
UseCaseModule { | |
@override | |
Widget build(BuildContext context) { | |
ScreenUtil.init(context); | |
return MultiBlocProvider( | |
providers: [ | |
BlocProvider(create: (_) => HomeBloc( | |
useCase: homeUseCase | |
)) | |
], | |
child: //.. | |
); | |
} | |
} |
That’s all, run the project, and let’s see what happens!
Pitfalls
In order to run this project, you need to run in a specific environment.
Development: flutter run -t lib/main_dev.dart
Production: flutter run -t lib/main_prod.dart
Top comments (0)