آموزش پیاده سازی معماری MVVM در فلاتر

اگر یک مهندس نرم افزار یا برنامه نویس تازه کار هستید حتما در پروژه هایی با این مشکل مواجه شده اید که به دلیل بزرگ شدن ابعاد کار اضافه کردن قابلیت های جدید و یا تغییرات در بخش های مختلف به یک چالش اساسی برای شما تبدیل شده است.
در این لحظه اهمیت کدنویسی تمیز و انتخاب یک معماری مناسب برای پروژه های برنامه نویسی بیش از پیش خود را نشان میدهد. به عنوان یک برنامه نویس موبایل یا هر پلتفرم دیگری علاوه بر مهارت کدنویسی باید به معماری نرم افزار و الگوهای طراحی مختلف اشراف خوبی داشته باشید تا بتوانید از بوجود آمدن مشکلات این چنینی در آینده جلوگیری کنید.
معماری MVVM
امروزه معماری های مورد استفاده در زمینه برنامه نویسی موبایل و غیره محدود هستند که یکی از محبوب ترین آنها معماری MVVM میباشد.
در این مقاله خیلی درباره مباحث تئوری الگوی MVVM ریز نخواهیم و هدف بررسی و پیاده سازی عملی آن در فریمورک فلاتر میباشد.
به طور کلی در این معماری هدف جداسازی بخش نمایش اطلاعات از قسمت منطقی برنامه است. برای view به هیچ عنوان نباید مهم باشد که اطلاعات از چه مکانی و به چه صورتی دریافت میشود.
Model: این بخش مسئول دریافت اطلاعات میباشد که در تعامل نزدیکی با viewmodel است.
View: بخش نمایش اطلاعات میباشد. همچنین viewmdel را از عملکردهای کاربر آگاه میکند.
ViewModel: اطلاعات دریافت شده به بخش view منتقل میکند. همانند یک پل بین دو بخش دیگر عمل میکند.

برای شروع کار به سه پکیج زیر احتیاج داریم تا آنها را وارد پروژه خود کنیم.
در ویدیو زیر نتیجه نهایی پروژه را مشاهده میکنید.
شروع کدنویسی
در اولین قدم کلاس پایه خود را به نام StateData ایجاد میکنیم که از این کلاس در آینده برای مدیریت state و طراحی رابط کاربری استفاده میکنیم.
abstract class StateData extends Equatable {
const StateData();
}
در قدم بعدی بخش ViewModel را پیاده سازی میکینم.
abstract class ViewModel<T extends StateData> implements ValueListenable<T> {
ViewModel(T initial) : _stateDataNotifier = ValueNotifier(initial);
final ValueNotifier<T> _stateDataNotifier;
@protected
set stateData(T value) => _stateDataNotifier.value = value;
@override
T get value => _stateDataNotifier.value;
@override
void addListener(VoidCallback listener) {
_stateDataNotifier.addListener(listener);
}
@override
void removeListener(VoidCallback listener) {
_stateDataNotifier.removeListener(listener);
}
@mustCallSuper
void dispose() {
_stateDataNotifier.dispose();
}
}
همانطور که گفتیم تمام بخش منطقی برنامه را سعی میکنیم در این قسمت قرار دهیم و فقط اطلاعاتی که توسط view درخواست میشود را به ویجت فلاتر مورد نظر انتقال میدهیم. منبع دریافت اطلاعات به هیچ عنوان مهم نیست که از دیتابیس یا وب سرویس میباشد.
برای دستیابی به این قابلیت از اینترفیس ValueListenable استفاده میکنیم که صفحه مورد نظر با گوش دادن به آن میتواند هر زمان اطلاعات جدیدی دریافت شد را نمایش دهد.
همچنین ابزار مفید دیگری به نام کلاس ValueNotifier در اختیار داریم که ValueListenable را پیاده سازی میکند و به عنوان نماینده بخش ViewModel عمل میکند. کار این کلاس جلوگیری از بازسازی ویجت مورد نظر بدون تغییر اطلاعات میباشد.
set value(T newValue) {
if (_value == newValue)
return;
_value = newValue;
notifyListeners();
}
مرحله نوبت به طراحی صفحه میباشد که ویجت از نوع StatefulWidget است.
abstract class Screen extends StatefulWidget {
const Screen({Key? key}) : super(key: key);
@override
ScreenState createState();
}
abstract class ScreenState<T extends Screen, T2 extends ViewModel<T3>,
T3 extends StateData> extends State<T> {
ScreenState();
late final T2 viewModel;
Widget buildScreen(BuildContext context);
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ValueListenableProvider<T3>.value(value: viewModel),
],
builder: (context, _) => buildScreen(context),
);
}
@override
@mustCallSuper
void initState() {
super.initState();
viewModel = context.read<ViewModelFactory>().create<T2>();
}
@override
@mustCallSuper
void dispose() {
viewModel.dispose();
super.dispose();
}
}
در این بخش هیچ اتفاق پیچیده ای رخ نداده است فقط در متد initState یک نمونه ViewModel ایجاد کرده ایم. همچنین در متد dispose هم منابع مصرفی توسط آن را آزاد کرده ایم. درباره ViewModelFactory هم نگران نباشید جلوتر در بخش تزریق وابستگی درباره آن صحبت خواهیم کرد.
حالا نوبت به ساخت اولین صفحه برنامه میرسد که این صفحه دارای یک viewmodel و یک stateData نیز میباشد.
کلاسی تحت عنوان HomeData ایجاد میکنیم و کدهای زیر را در آن مینویسیم.
class HomeData extends StateData {
const HomeData({
required this.todoTiles,
required this.showEmptyState,
required this.showLoading,
});
const HomeData.initial()
: todoTiles = const [],
showLoading = false,
showEmptyState = false;
final List<TodoTileData> todoTiles;
final bool showLoading;
final bool showEmptyState;
@override
List<Object?> get props => [
todoTiles,
showLoading,
showEmptyState,
];
}
همانطور که از کدها احتمالا متوجه شده اید صفحه مورد نظر که قرار است یک لیستی از کارهای قابل انجام را نمایش دهد شامل سه حالت مختلف میباشد. خالی, در حال لود شدن و حالت سوم نمایش لیست میباشد.
کلاس دیگری بصورت زیر برای بخش viewmodel ایجاد میکنیم.
@injectable
class HomeViewModel extends ViewModel<HomeData> {
HomeViewModel(
this._todoRepository,
) : super(const HomeData.initial());
final TodoRepository _todoRepository;
StreamSubscription<List<TodoTileData>>? _todoSub;
void init() {
_updateList();
_todoSub = _todoRepository
.observeTodoList()
.map((todoList) => todoList.map(TodoTileData.fromTodo).toList())
.listen(_onTodoChanged);
}
@override
void dispose() {
_todoSub?.cancel();
super.dispose();
}
Future<void> changeTodoStatus(String id, bool isDone) async {
await _todoRepository.updateTodoStatus(id, isDone);
}
void _updateState({
List<TodoTileData>? tiles,
bool? isLoading,
}) {
tiles ??= value.todoTiles;
isLoading ??= value.showLoading;
stateData = HomeData(
todoTiles: tiles,
showEmptyState: tiles.isEmpty && !isLoading,
showLoading: isLoading,
);
}
Future<void> _updateList() async {
_updateState(isLoading: true);
await _todoRepository.updateTodoList();
_updateState(isLoading: false);
}
void _onTodoChanged(List<TodoTileData> tiles) {
_updateState(tiles: tiles);
}
}
به متد _updateState توجه کنید. تمام پارامترهای این متد اختیاری هستند و میتوانید فقط یک بخش را مثل _updateList بروزرسانی کنید. همچنین در این بخش stateData ساخته میشود و اطلاعات منطقی برنامه را در خود نگهداری میکند.
نوبت به تکمیل کردن بخش صفحه میباشد. همانطور که گفتیم صفحه ما شامل سه حالت میباشد پس به سه ویحت نیز نیاز داریم. اما این ویجت ها چطوری به اطلاعات صفحه دسترسی پیدا میکنند؟ از طریق کلاس Provider که در پروژه های فلاتر هم بسیار از آن استفاده میشود.
class _ProgressBar extends StatelessWidget {
const _ProgressBar({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Selector<HomeData, bool>(
selector: (_, data) => data.showLoading,
builder: (context, showLoading, _) => Visibility(
visible: showLoading,
child: const CircularProgressIndicator(),
),
);
}
}
کار این ویجت تنها نمایش یک حالت لودینگ به کاربر میباشد.
class HomeScreen extends Screen {
const HomeScreen({Key? key}) : super(key: key);
@override
_HomeScreenState createState() => _HomeScreenState();
}
class _HomeScreenState
extends ScreenState<HomeScreen, HomeViewModel, HomeData> {
@override
void initState() {
super.initState();
viewModel.init();
}
@override
Widget buildScreen(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Todo List')),
body: Stack(
fit: StackFit.expand,
children: [
_TodoList(
changeListener: _todoChangeListener,
),
const Center(child: _ProgressBar()),
const _EmptyState(),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () {
CreateTodoScreenRoute().push(context);
},
child: const Icon(Icons.add),
),
);
}
void _todoChangeListener(TodoChange change) {
viewModel.changeTodoStatus(change.id, change.isDone);
}
}
class _ProgressBar extends StatelessWidget {
const _ProgressBar({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Selector<HomeData, bool>(
selector: (_, data) => data.showLoading,
builder: (context, showLoading, _) => Visibility(
visible: showLoading,
child: const CircularProgressIndicator(),
),
);
}
}
class _EmptyState extends StatelessWidget {
const _EmptyState({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Selector<HomeData, bool>(
selector: (_, data) => data.showEmptyState,
builder: (context, showEmptyState, _) => Visibility(
visible: showEmptyState,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
child: Text(
'Add something first',
style: Theme.of(context).textTheme.headline4,
),
),
),
);
}
}
class _TodoList extends StatelessWidget {
const _TodoList({
Key? key,
required this.changeListener,
}) : super(key: key);
final ValueChanged<TodoChange> changeListener;
@override
Widget build(BuildContext context) {
return Selector<HomeData, List<TodoTileData>>(
selector: (_, data) => data.todoTiles,
builder: (context, todoTiles, _) {
return ListView.separated(
itemBuilder: (context, index) {
final data = todoTiles[index];
return TodoTile(
key: ValueKey(data.id),
data: data,
changeListener: changeListener,
);
},
separatorBuilder: (_, __) => const Divider(),
itemCount: todoTiles.length,
);
},
);
}
}
تزریق وابستگی Dependency injection
هر کلاس ViewModel ما از ترکیب سه پکیجی که در ابتدای آموزش قرار داده بودیم تشکیل شده است. همانطور که دیدید کلاس HomeViewModel شامل دستور @injectable بود که به معنی آن است که قابلیت ارائه به get_it را برای ایجاد نمونه از کلاس را دارد. اما برای پیاده سازی این کار نیاز به ساخت دو کلاس جدید داریم.
- ViewModelFactory برای ساخت نمونه از کلاس viewmodel
- GlobalProvider که از طریق آن صفحه مورد نظر به وسیله BuildContext به ViewModelFactory دسترسی خواهد داشت.
abstract class ViewModelFactory {
T create<T extends ViewModel>();
}
@Singleton(as: ViewModelFactory)
class ViewModelFactoryImpl implements ViewModelFactory {
@override
T create<T extends ViewModel>() => getIt.get<T>();
}
طراحی کلاس ViewModelFactory مشکل خاصی ندارد. GlobalProvider نیز یک ویجت است که تمام پروژه را داخل آن قرار میدهیم.
class GlobalProvider extends StatelessWidget {
const GlobalProvider({
Key? key,
required this.child,
}) : super(key: key);
final Widget child;
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
Provider.value(value: getIt.get<ViewModelFactory>()),
],
child: child,
);
}
}
امیدوارم که این آموزش برای شما مفید بوده باشد.
دیدگاهتان را بنویسید