ایجاد سرویس در اپلیکیشن فلاتر — از صفر تا صد
ایده اصلی استفاده از الگوی MVVM برای یک اپلیکیشن فلاتر آن است که منطق را از لیآوت UI حذف کنیم و در مدل View قرار دهیم. بدین ترتیب کد اپلیکیشن تمیزتر میماند و نگهداری آن نیز آسانتر میشود. در این مقاله در مورد روش ایجاد سرویس در اپلیکیشن فلاتر صحبت خواهیم کرد.
اما این که همه کارها را بر عهده مدل View قرار دهیم نیز اشتباه است. وظیفه اصلی مدل View آماده کردن دادهها برای نمایش در View است. کارهای سنگین باید به سرویسها واگذار شوند. این یک گام در مسیر جداسازی دغدغهها محسوب میشود که برای داشتن اپلیکیشنهای با نگهداری مناسب ضروری است.
در این مقاله در مورد سرویسها به تفصیل صحبت میکنیم و نشان میدهیم که چگونه میتوان سرویسهایی برای یک اپلیکیشن فلاتر ساخت. توجه کنید که بحث استفاده از سرویس ربطی به استفاده از الگوی MVVM ندارد و شما میتوانید در صورت استفاده از معماریهای دیگر نیز همچنان از سرویسها بهره بگیرید.
سرویس چیست؟
سرویس صرفاً یک کلاس نرمال در زبان «دارت» (Dart) است و نباید آن را با مفهوم سرویس در اندروید اشتباه گرفت. چون در اندروید منظور از سرویس یک وظیفه با اجرای طولانیمدت در پسزمینه است. اما سرویسها در فلاتر صرفاً کلاسهایی هستند که برای اجرای یک کار خاص در اپلیکیشن مینویسیم. حتی لزومی ندارد که آنها را سرویس بنامید. تنها دلیل این که آنها را سرویس نامگذاری کردهایم این است که در فرهنگ توسعهدهندگان فلاتر چنین جا افتاده است. آنها را میتوان helper یا میکروسرویس نیز نامید. اما سرویس نام مناسبی است و با مفهوم میکروسرویس نیز مطابقت دارد. با این تفاوت که به جای این که جدا از اپلیکیشن و روی شبکه باشد، داخل اپلیکیشن قرار دارد.
برخی از مواردی که ممکن است بخواهید از سرویسها استفاده کنید در ادامه لیست شدهاند:
- خواندن و نوشتن در local storage (پایگاه داده، shared preferences، فایلها)
- دسترسی به یک API وب
- لاگین کردن یک کاربر
- اجرای نوعی محاسبات سنگین
- قرار دادن فایربیس یا دیگر پکیجهای شخص ثالث
هدف از سرویس چیست؟
هدف از سرویس، جداسازی یک وظیفه و پنهان کردن جزییات پیادهسازی آن از بقیه قسمتهای اپلیکیشن است.
فرض کنید از shared preferences برای ذخیرهسازی برخی دادههای کاربر استفاده میکنید. شما باید این دادهها را در چند نقطه اپلیکیشن ذخیره و بازیابی کنید.
ممکن است متوجه شوید که باید حجم زیادی از دادهها را ذخیره کنید و از این رو تصمیم میگیرید از یک وهله از پایگاه داده SQL به جای shared preferences استفاده کنید. بدین ترتیب همه ارجاعها به shared preferences را با کد پایگاه داده عوض میکنید. اما یک موقعیت را فراموش میکنید و باگی ایجاد میشود.
تصور کنید پکیج پایگاه دادهای که استفاده میکنید، دیگر نگهداری و بهروز نمیشود و یا پکیج محبوب دیگری وجود دارد که میخواهید از آن استفاده کنید. بدین ترتیب باید همه ارجاعها را در کد خود ویرایش کنید که کار دشواری است. هر جا هم که یک پارامتر را فراموش کنید، باگی رخ میدهد.
تصور کنید اپلیکیشنتان محبوبیت زیادی کسب میکند، اما کاربران شروع به درخواست همگامسازی روی دستگاههای مختلف میکنند. بنابراین تصمیم میگیرید به جای ذخیره کردن دادهها در حافظه لوکال، آنها را روی سرور ذخیره کنید. بدین منظور باید کد را در همه بخشهای اپلیکیشن تغییر دهید که کار دشواری است. از آنجا که نمیخواهید این دشواری را بر عهده بگیرید، آن را به فرد دیگری میسپارید و بدین ترتیب زمینه بروز باگ پیش میآید.
در ادامه تصور کنید اپلیکیشنتان محبوبیت باز هم بیشتری به دست میآورد، اما فشار سنگین روی سرور، بر روی عملکرد آن تأثیر میگذارد. بنابراین میخواهید از یک راهحل مبتنی بر فضای ابری برای بکاند خود استفاده کنید که با همان سبک معماری فرانتاند شما ساخته شده است. هزینه تغییر بسیار بالا است و ممکن است باگهای بسیار زیادی بروز یابند. بنابراین اپلیکیشن رو به انحطاط میگذارد و کاربران آن را ترک میکنند.
نکته مثالهای فوق این است که شما در اپلیکیشن خود به برخی کارکردها پیوند یافتهاید که در تمام نقاط اپلیکیشن وجود دارند و بدین ترتیب هر گونه تغییری زمینه بروز خطا را فراهم میسازد.
در این حالت باید از سرویسها استفاده کنیم. یک کلاس جدید ایجاد میکنیم و نام آن را چیزی مانند StorageService میگذاریم. بقیه کلاسها در اپلیکیشن در مورد کارکرد درونی آن چیزی نمیدانند. آنها صرفاً متدهای روی سرویس را برای ذخیرهسازی و بازیابی دادهها فراخوانی میکنند.
بدین ترتیب ایجاد تغییر آسان میشود. اگر بخواهید به جای shared preferences از پایگاه داده استفاده کنید، این کار بدون هیچ مشکلی قابل اجرا است. کافی است کد درون کلاس سرویس را تغییر دهید. همین موضوع در مورد سوئیچ کردن از پکیجهای پایگاه داده به یک API وب نیز صدق میکند. با بهروزرسانی کد سرویس همه چیز در هر نقطه از اپلیکیشن که از آن سرویس استفاده میکند به صورت خودکار بهروزرسانی میشود.
نکته مهمتر این است که میتوانید سرویس را به صورت یک کلاس مجرد با متدهایی که میخواهید در اختیار اپلیکیشن قرار گیرد، اعلان کنید. در این حالت کلاس سرویس مجرد میتواند با پیادهسازیهای پایداری بسط یابد.
بدین ترتیب میتوانید به راحتی پیادهسازیها را با هم عوض کنید. همچنین میتوانید یک پیادهسازی «جعلی» بسازید که صرفاً دادههای هاردکد شده بازگشت میدهد. این کار به شما کمک میکند که روی بقیه اپلیکیشن متمرکز شوید و کدنویسی سرویس را به آینده موکول نمایید. این همچنین روشی برای تقسیم وظایف در میان تیم توسعه اپلیکیشن محسوب میشود.
پیادهسازی شما ممکن است به چند سرویس دیگر وابسته باشد. برای نمونه سرویس StorageService ممکن است از یک سرویس ذخیرهسازی لوکال برای برخی انواع داده و یک سرویس شبکه برای انواع دیگری از داده بهره بگیرد در این حالت نیز جزییات این پیادهسازی برای بقیه اپلیکیشن اهمیتی ندارد.
مثال
در این بخش با روش ساخت یک سرویس واقعی آشنا میشویم. زمانی که چند بار این کار را انجام دهید برایتان بسیار آسان خواهد شد. در این مثال از الگوی MVVM استفاده میکنیم. مدل View از سرویس برای دریافت برخی دادهها استفاده میکند و سپس به شنوندههای خود (یعنی View) اطلاعرسانی میکند. به این ترتیب UI میتواند بهروزرسانی شود. در صورتی که به جای مدلهای View از الگو BLoC نیز استفاده کنید همین وضعیت خواهد بود. مراحل کار برای راهاندازی سرویس به صورت زیر است:
تعریف کردن سرویس با یک کلاس مجرد
یک سرویس ذخیرهسازی ایجاد میکنیم که یک عدد صحیح را ذخیره و بازیابی خواهد کرد. جایی که دادهها ذخیره شوند، در حال حاضر اهمیتی ندارد. ما صرفاً چگونگی تعامل اپلیکیشن با سرویس را تعریف میکنیم. یک فایل به نام storage_service.dart در پوشه /lib ایجاد کنید. کد زیر را در آن قرار دهید:
1abstract class StorageService {
2 Future<int> getCounterValue();
3 Future<void> saveCounterValue(int value);
4}
از آنجا که دادههای اپلیکیشن ما به صورت اعداد صحیح هستند و متد داریم که یکی برای ذخیره یک int و دیگری برای گرفتن آن است. این یک نقشه اولیه برای پیادهسازی واقعی سرویس محسوب میشود. این کلاس مجرد یک کران ایجاد میکند و آزاد هستیم که در هر سوی کران عمل کنیم. ما میتوانستیم روی پیادهسازی سرویس ذخیرهسازی کار کنیم، یا اینکه صرفاً از سرویس ذخیرهسازی در اپلیکیشن خود طوری استفاده کنیم که گویی از قبل آماده است این بار پیشتر میرویم و سرویس را پیادهسازی میکنیم.
پیادهسازی کلاس سرویس مجرد
در طی زمان توسعه احتمالاً کار را با ایجاد یک سرویس جعلی آغاز میکنیم که دادههای ساختگی بازگشت میدهد. بدین ترتیب میتوانیم روی ساخت بقیه بخشهای اپلیکیشن متمرکز شویم و فعلاً در مورد شیوه پیادهسازی سرویس واقعی نگرانی نداشته باشیم.
پیادهسازی جعلی
این یک پیادهسازی جعلی است و ما در عمل هیچ چیزی را ذخیره نمیکنیم و زمانی که از آن دادهای بخواهیم، صرفاً دادههای ساختگی بازگشت میدهد. یک فایل به نام storage_service_fake.dart ایجاد کنید و کد زیر را در آن قرار دهید:
1import 'storage_service.dart';
2
3class StorageServiceFake extends StorageService {
4 @override
5 Future<int> getCounterValue() async {
6 return 11;
7 }
8
9 @override
10 Future<void> saveCounterValue(int value) async {
11 // do nothing
12 }
13}
پیادهسازی Shared Preferences
این پیادهسازی موجب میشود که دادهها از Shared Preferences بازیابی شده و در آن ذخیره شوند. بنابراین یک فایل به نام storage_service_shared_pref.dart ایجاد کرده و کد زیر را به آن اضافه کنید:
1import 'package:shared_preferences/shared_preferences.dart';
2import 'storage_service.dart';
3
4class StorageServiceSharedPreferences extends StorageService {
5 @override
6 Future<int> getCounterValue() async {
7 final prefs = await SharedPreferences.getInstance();
8 return prefs.getInt('counter_int_key') ?? 0;
9 }
10
11 @override
12 Future<void> saveCounterValue(int value) async {
13 final prefs = await SharedPreferences.getInstance();
14 prefs.setInt('counter_int_key', value);
15 }
16}
به این ترتیب مقدار شمارنده در shared preferences ذخیره میشود که یک حافظه لوکال روی دستگاه کاربر محسوب میشود.
پیادهسازی پایگاه داده Sqflite
میدانیم که ذخیرهسازی یک عدد صحیح منفرد در پایگاه داده قطعاً کاری عقلانی نیست، اما این بخش را به این خاطر آوردهایم که تأکید کنیم نوع پیادهسازی برای بقیه اپلیکیشن اهمیتی ندارد. در این پیادهسازی دادهها را در یک پایگاه داده Sqflite ذخیره کرده و از آن بازیابی میکنیم. یک فایل به نام storage_service_database.dart ایجاد کرده و کد زیر را در آن قرار دهید:
1import 'dart:io';
2import 'package:path/path.dart';
3import 'package:path_provider/path_provider.dart';
4import 'package:sqflite/sqflite.dart';
5import 'storage_service.dart';
6
7class StorageServiceDatabase extends StorageService {
8
9 @override
10 Future<int> getCounterValue() async {
11 Database db = await DatabaseHelper.instance.database;
12 List<String> columnsToSelect = [
13 DatabaseHelper.columnCounter,
14 ];
15 String whereString = '${DatabaseHelper.columnId} = ?';
16 int rowId = 1;
17 List<dynamic> whereArguments = [rowId];
18 List<Map> result = await db.query(
19 DatabaseHelper.table,
20 columns: columnsToSelect,
21 where: whereString,
22 whereArgs: whereArguments);
23
24 Map firstRow = result.first;
25 return firstRow[DatabaseHelper.columnCounter] ?? 0;
26 }
27
28 @override
29 Future<void> saveCounterValue(int value) async {
30 Map<String, dynamic> row = {
31 DatabaseHelper.columnCounter: value
32 };
33 Database db = await DatabaseHelper.instance.database;
34 await db.update(DatabaseHelper.table, row);
35 }
36}
37
38class DatabaseHelper {
39
40 static final _databaseName = "CounterDatabase.db";
41 static final _databaseVersion = 1;
42
43 static final table = 'counter_table';
44
45 static final columnId = '_id';
46 static final columnCounter = 'counter';
47
48 DatabaseHelper._privateConstructor();
49 static final DatabaseHelper instance = DatabaseHelper._privateConstructor();
50
51 static Database _database;
52 Future<Database> get database async {
53 if (_database != null) return _database;
54 _database = await _initDatabase();
55 return _database;
56 }
57
58 _initDatabase() async {
59 Directory documentsDirectory = await getApplicationDocumentsDirectory();
60 String path = join(documentsDirectory.path, _databaseName);
61 return await openDatabase(path,
62 version: _databaseVersion,
63 onCreate: _onCreate);
64 }
65
66 Future _onCreate(Database db, int version) async {
67 await db.execute('''
68 CREATE TABLE $table (
69 $columnId INTEGER PRIMARY KEY,
70 $columnCounter INTEGER NOT NULL
71 )
72 ''');
73 }
74}
پیادهسازی شبکه
در این پیادهسازی، دادهها از یک سرور ریموت خوانده شده و در آن نوشته میشوند. ما در عمل یک سرور ریموت برای این پیادهسازی ایجاد نکردهایم و از این رو کد زیر تست نشده است. برای ایجاد درخواستهای HTTP در دارت میتوانید از پکیج http استفاده کنید. فایلی به نام storage_service_web.dart بسازید و کد زیر را در آن قرار دهید:
1import 'dart:convert';
2import 'package:http/http.dart';
3import 'storage_service.dart';
4
5class StorageServiceWeb extends StorageService {
6
7 @override
8 Future<int> getCounterValue() async {
9 String url = 'https://example.com/counter';
10 Response response = await get(url);
11 String json = response.body;
12 Map<String, dynamic> map = jsonDecode(json);
13 int counterValue = map['counter'];
14 return counterValue;
15 }
16
17 @override
18 Future<void> saveCounterValue(int value) async {
19 String url = 'https://example.com/counter';
20 Map<String, String> headers = {'Content-type': 'application/json'};
21 String json = '{"counter": $value}';
22 await post(url, headers: headers, body: json);
23 }
24}
قطعاً گزینههای پیادهسازی زیاد دیگری نیز برای کلاس مجرد StorageService که در بخش قبل تعریف کردیم، وجود دارند. امیدواریم مثالهای پیادهسازی که ارائه شدند، ایدهای کلی در مورد روش انجام کار به شما داده باشند.
ایجاد یک locator سرویس
یک locator سرویس به مکانی مرکزی گفته میشود که همه سرویسهایی مورد استفاده در اپلیکیشن در آن ثبت میشوند. با استفاده از این locator میتوانید از هر جایی در کد خود به سرویسها دسترسی داشته باشید. در واقع این locator جایگزینی برای تزریق وابستگی محسوب میشود. برخی افراد شکایت میکنند که locator-های سرویس یک ضد الگو محسوب میشوند و تست آنها دشوار است. در صورت ترجیح میتوانید از سرویسهای خود بدون بهرهگیری از locator استفاده کنید. کافی است سرویس را در سازنده کلاسی که آن را نیاز دارد تزریق کنید.
با این حال locator-های سرویس واقعاً جذاب هستند و استفاده از آنها کاملاً آسان است. تلاش برای تزریق وابستگی با استفاده از چیزی مانند ProxyProvider میتواند بسیار پیچیده باشد. همواره بهتر است کارها را به شیوه آسانتر انجام دهیم. برای این گزاره که locator-های سرویس ضد الگو هستند، جای دفاع چندانی وجود ندارد، اما این که تست آنها دشوار است گزینه صحیحی محسوب نمیشود. در ادامه نشان میدهیم که چگونه میتوانید به سادگی یک کلاس که از locator-های سرویس استفاده میکند را تست کنید. محبوبترین پکیج locator سرویس برای فلاتر پکیج GetIt (+) است. با استفاده از افزودن وابستگی زیر به فایل pubspec.yaml میتوانید آن را به دست آورید:
dependencies: get_it: ^3.1.0
سپس یک فایل به نام service_locator.dart در پوشه /lib ایجاد کرده و کد زیر را در آن وارد نمایید:
1import 'storage_service_fake.dart';
2import 'package:get_it/get_it.dart';
3import 'storage_service.dart';
4
5GetIt locator = GetIt.instance;
6
7setupServiceLocator() {
8 locator.registerLazySingleton<StorageService>(() => StorageServiceFake());
9}
بدین ترتیب یک متغیر سراسری به نام locator به دست میآورید که میتوانید از هر جایی در اپلیکیشن خود به آن دسترسی داشته باشید. این یک service locator است. سرویسها را در متد زیر آن که در زمان آغاز به کار اپلیکیشن اجرا میشود، ثبت کنید. توجه کنید که ما StorageService را به عنوان یک سینگلتون «با تأخیر» (Lazy) ثبت کردهایم. یعنی تنها زمانی که نخستین بار مورد استفاده قرار گیرد، مقداردهی خواهد شد. اگر میخواهید آن را در ابتدای راهاندازی اپلیکیشن، مقداردهی کنید، باید به جای آن از ()registerSingleton استفاده کنید. از آنجا که این یک سینگلتون است، همواره باید از یک وهله از سرویس استفاده کنید.
همچنین توجه کنید که StorageServiceFake را به عنوان یک پیادهسازی پایدار از StorageService ثبت کردهایم. این همان جایی است که زیباییهای کلاس مجرد نمایان میشود. اگر بخواهیم از یکی از پیادهسازیهای دیگر که پیشتر نوشتیم استفاده کنیم، تنها کاری که باید انجام دهیم این است که این یک خط کد را عوض کنیم. ما تنها یک سرویس یعنی StorageService را در اینجا ثبت کردهایم، شما میتوانید چندین سرویس دیگر را ثبت میکنید. برای نمونه میتوانید یک سرویس لاگین یا سرویس فایربیس ثبت کنید.
مقداردهی locator سرویس
سرویسها باید در ابتدای آغاز به کار اپلیکیشن (startup) ثبت شوند و این کار باید در فایل main.dart انجام یابد. بنابراین کد استاندارد زیر را:
1void main() => runApp(MyApp());
با کد زیر عوض کنید:
1import 'service_locator.dart';
2void main() {
3 setupServiceLocator();
4 runApp(MyApp());
5}
بدین ترتیب هر سرویسی که با GetIt پیش از درخت ویجت بیلد شده باشد، ثبت خواهد شد.
دریافت سرویس
اکنون که locator سرویس، مقداردهی شده و سرویسها ثبت شدهاند، میتوانیم از هر جایی در اپلیکیشن به این سرویسها ارجاع به دست آوریم. یک کلاس مدل به نام counter_viewmodel.dart ایجاد کرده و کد زیر را در آن قرار دهید:
1import 'package:flutter/foundation.dart';
2import 'service_locator.dart';
3import 'storage_service.dart';
4
5class CounterViewModel extends ChangeNotifier {
6 int _counter = 0;
7 int get counter => _counter;
8
9 StorageService _storageService = locator<StorageService>();
10
11 Future loadData() async {
12 _counter = await _storageService.getCounterValue();
13 notifyListeners();
14 }
15
16 void increment() {
17 _counter++;
18 _storageService.saveCounterValue(_counter);
19 notifyListeners();
20 }
21}
تنها کاری که برای یافتن سرویس باید انجام دهید، این است که نوع سرویس را به locator سرویس یعنی ()<locator<StorageService اعلام کنید. بدین ترتیب GetIt میتواند آن را به دست آورد. پس از آن میتوانید از سرویس مورد نظر بخواهید که کار خود را انجام دهد. توجه کنید که کد فوق هیچ ارجاعی به پیادهسازی پایدار سرویس ندارد. این مسئله جزء جزییات داخلی است که اهمتی ندارد.
تست کلاسهایی که از سرویس استفاده میکنند
تست کلاسهایی که از locator سرویس استفاده میکنند، علیرغم اینکه چیزی به سازنده ارسال نشده تا شبیهسازی شود، کار آسانی است. برای نمونه کلاس مدل فوق را تست میکنیم. در این تست از پکیج mockito (+) استفاده خواهیم کرد. در پوشه test/ یک فایل به نام counter_viewmodel_test.dart ایجاد کرده و کد زیر را به آن اضافه کنید:
1import 'package:flutter_architecture_example/counter_viewmodel.dart';
2import 'package:flutter_architecture_example/service_locator.dart';
3import 'package:flutter_architecture_example/storage_service.dart';
4import 'package:flutter_test/flutter_test.dart';
5import 'package:mockito/mockito.dart';
6
7class MockStorageService extends Mock implements StorageService {}
8
9void main() {
10 setUpAll(() {
11 setupServiceLocator();
12 locator.allowReassignment = true;
13 });
14
15 test(
16 'should increment counter',
17 () async {
18 // reassign storage service with a mock
19 var mockStorageService = MockStorageService();
20 when(mockStorageService.getCounterValue()).thenAnswer(
21 (_) => Future.value(0),
22 );
23 locator.registerSingleton<StorageService>(mockStorageService);
24
25 // increment counter
26 final viewModel = CounterViewModel();
27 viewModel.increment();
28
29 expect(viewModel.counter, 1);
30 },
31 );
32}
کلید شبیهسازی locator سرویس این است که مقدار allowReassignment را در متد ()setUpAll روی true قرار دهیم. پس از آن میتوانید سرویس را در مدل view با یک نسخه شبیهسازی تعویض کنید. کد کامل اپلیکیشن مورد بررسی در این مقاله در این ریپوی گیتهاب (+) ارائه شده است. در فایل service_locator.dart جای StorageServiceFake و StorageServiceSharedPreferences را عوض کنید و سپس اپلیکیشن را اجرا نمایید. کلید افزایش را چند بار بزنید و اپلیکیشن را مجدداً اجرا کنید. هر بار باید مقدار ذخیرهشده را از سرویس دریافت کند.
سخن پایانی
سرویسها ابزارهایی برای جداسازی کارکردها از بقیه بخشهای اپلیکیشن محسوب میشوند. به خصوص این وضعیت در مورد پکیجهای شخص ثالث که فرار هستند و ممکن است بخواهید در آینده عوض کنید، صدق میکند. استفاده از یک لوکیتور سرویس مانند GetIt روشی آسان برای ارائه این سرویسها در سراسر اپلیکیشن است.
اگر این مطلب برای شما مفید بوده است، آموزشهای زیر نیز به شما پیشنهاد میشوند:
- مجموعه آموزشهای برنامهنویسی اندروید
- مجموعه آموزشهای برنامهنویسی
- گوگل فلاتر (Flutter) از صفر تا صد — ساخت اپلیکیشن به کمک ویجت
- فلاتر برای وب — راهنمای مقدماتی
- مفاهیم مقدماتی فلاتر (Flutter) — به زبان ساده
==
خیلی عالی بود . به انتشار مطالب بیشتر راجب فلاتر حتما فکر کنید و ادامه بدید . واقعا خیلی خوب و روان توضیح دادید . بیشتر راجب فلاتر مطلب قرار بدید .