Лейблинг застосунків за допомогою Flutter: BuildVariants vs Dependencies

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Привіт! Мене звати Антон, я Senior Flutter Developer у компанії Newsoft із загальним досвідом понад 10 років у мобільній розробці.

Сьогодні ми разом розберемо та порівняємо декілька способів лейблингу застосунків за допомогою Flutter. Перед тим, як почати, пропоную всім охочим ознайомитись з кодом проєктів за цим посиланням. А тепер до роботи!

White Labeling — це процес створення універсальних застосунків, які можна налаштувати під різні бренди або компанії, дозволяючи легко змінювати елементи дизайну, як-от кольори, логотипи та інші брендові атрибути.

Такий підхід дозволяє значно зекономити час та ресурси на розробку окремих застосунків для кожного клієнта, адже основний функціонал залишається спільним, а індивідуальні елементи дизайну легко адаптуються під конкретні вимоги.

Чому саме Flutter

Flutter — це фреймворк від Google, створений для кросплатформної розробки, що вже п’ять років активно розвивається, має постійно зростаюче комʼюніті та підтримує всі основні платформи для розробки застосунків.

Загалом також варто згадати, що у Flutter прекрасна продуктивність, максимально наближена до нативної, він має зручні інструменти для розробки (як-от hot reload, hot restart, dev tools), а ще він зазвичай дешевший, оскільки для розробки одного застосунку на різні платформи не потрібно окремих спеціалістів для кожної з них.

Саме для брендування та створення white label проєкту Flutter підходить на 100%, оскільки має зручні та прості механізми конфігурації, що відрізняються від звичних flavors та scheme в Android та iOS відповідно.

В офіційній документації Flutter згадуються описаний механізм flavors та те, як його налаштувати для вашого застосунку, тому й із цим також проблем не має бути. Але, на мою думку, є простіший спосіб, і саме про нього ми сьогодні поговоримо.

Задача

Нарешті ми дійшли до найцікавішого. Так-от, уявімо, що ми придумали та хочемо продавати різним брендам наш неймовірно зручний інтерфейс магазину/мейл-клієнта/читалки/тудушки (потрібне підкреслити). Але як цього досягти швидко, ефективно та зручно?

Для початку розберемось, які саме складові застосунку можуть бути змінені для різних брендів. Основні пункти, що приходять мені на думку першими, такі:

  • тема застосунку, тобто його кольори, розміри та стилі тексту, тощо;
  • логотипи, іконки та інші асети (можна було б класифікувати й до попереднього пункту, але, на мою думку, цьому варто приділити окрему увагу);
  • локалізація та підтримувані мови;
  • фічі, набір яких може змінюватись відносно бренду (якісь можна вимикати, інші — вмикати);

Отже нашим завданням буде розібратись, як же можна налаштувати мультибрендовий застосунок покроково за всіма вищезазначеними пунктами. Для цього пропоную порівняти між собою два основних підходи до лейблингу застосунків у Flutter: BuildTypes та Dependencies.

BuildVariant = ProductFlavors + BuildTypes

BuildVariants (відомі як SchemeConfigurations в iOS) дозволяють створювати варіанти збірки для різних версій вашої програми з одного проєкту. Кожен варіант збірки є окремою версією вашої програми. У нашому випадку це ніби ідеальний варіант для створення різних конфігурацій для кожного з брендів.

В Android BuildVariant складається з комбінацї ProductFlavor та BuildType. Своєю чергою, ProductFlavor опише налаштування окремого бренду, як-от унікальний ідентифікатор застосунку та публічне імʼя, а BuildType - різні типи білдів для розробки, тестування та релізу застосунку.

Ну що, тримаємо цю інформацію в голові та переходимо до реалізації!

Setup

Скориставшись офіційною документацією Flutter, створюємо демо проєкт та описуємо основні flavors для базового white label проєкту. Flutter любʼязно генерує нам демо counter app, який ми і будемо лейблити.

В build.gradle-файлі Android-проєкту створюємо та описуємо наші productFlavors. Додаємо лейбли: base (базовий каунтер, що просто додає один під час натискання кнопки), fib (Фібоначі каунтер, що буде додавати два попередніх числа під час натискання кнопки) та doub (Каунтер лейбл, у якому число дублюється).

Для кожного з них перевизначимо ідентифікатор та імʼя застосунку:

flavorDimensions "label"
productFlavors {
   base {
       dimension "label"
       applicationId "com.example.base"
       resValue "string", "app_name", "Base"
   }
   fib {
       dimension "label"
       applicationId "com.example.fib"
       resValue "string", "app_name", "Fib"
   }
   doub {
       dimension "label"
       applicationId "com.example.double"
       resValue "string", "app_name", "Double"
   }
}

Також додаємо альтернативні BuildTypes, що допоможуть нам розділити білди на релізні, тестові та робочі за допомогою додавання суфіксу до ідентифікатора:

buildTypes {
   debug {
       applicationIdSuffix ".debug"
       debuggable true
   }
   profile {
       applicationIdSuffix ".profile"
   }
   release {
       signingConfig signingConfigs.debug
   }
}

Для iOS так само налаштуємо SchemeConfigurations. Тут мають місце деякі відмінності, але принцип схожий. Також потрібно окремо задати ідентифікатор та імʼя застосунку, як описано в офіційній документації Flutter.

Config

Наступним кроком до налаштування буде створення конфігураційного класу, назвемо його AppConfig. Загалом з назви зрозуміло, що для кожного лейблу в нас буде окрема конфігурація, а її інтерфейс опишемо в базовому sealed-класі таким чином:

sealed class AppConfig {
 final BuildType _buildType;
 const AppConfig(this._buildType);
 BuildType get buildType => _buildType;
 String get appName;
 ThemeData get theme;
 String get assetsPath;
 List<Locale> get supportedLocales;
}

Цей конфіг створено за нашими потребами для кожного лейбла, у ньому визначаємо тип збірки, імʼя, тему, шлях до асетів та список мов, що підтримуються. Тепер пронаслідуємо від нього три конфіга (BaseConfig, DoubConfig, FibConfig) для трьох типів аплікації, що ми визначили раніше:

class BaseConfig extends AppConfig {
 BaseConfig(super.flavor);
 @override
 String get appName => 'Base';
 @override
 ThemeData get theme => ThemeData(
       useMaterial3: true,
       colorScheme: ColorScheme.fromSeed(
         seedColor: Colors.blue,
       ),
     );
 @override
 String get assetsPath => 'assets/base';
 @override
 List<Locale> get supportedLocales => const <Locale>[
       Locale('en', 'US'),
       Locale('uk', 'US'),
     ];
}
class DoubConfig extends AppConfig {
 DoubConfig(super.flavor);
 @override
 String get appName => 'Double';
 @override
 ThemeData get theme => ThemeData(
       useMaterial3: true,
       colorScheme: ColorScheme.fromSeed(
         seedColor: Colors.green,
       ),
     );
 @override
 String get assetsPath => 'assets/double';
 @override
 List<Locale> get supportedLocales => const <Locale>[
       Locale('uk', 'US'),
     ];
}
class FibConfig extends AppConfig {
 FibConfig(super.flavor);
 @override
 String get appName => 'Fib';
 @override
 ThemeData get theme => ThemeData(
       useMaterial3: true,
       colorScheme: ColorScheme.fromSeed(
         seedColor: Colors.yellow,
       ),
     );
 @override
 String get assetsPath => 'assets/fib';
 @override
 List<Locale> get supportedLocales => const <Locale>[
       Locale('en', 'US'),
     ];
}

Ці конфіги ми в далі використаємо в апці для налаштувань кожного лейбла окремо.

Але спочатку потрібно якось дістати flavor та buildType. Для цього ми створимо MethodChannel (механізм, що дозволяє викликати фрагменти коду з нативних платформ). Для Android та iOS потрібно зареєструвати іменований канал, що буде повертати дані про обраний flavor.

AppDelegate.swift iOS:

import UIKit
import Flutter
let kChannel = "flavor"
let kMethodFlavor = "getFlavor"
@UIApplicationMain
@objc class AppDelegate: FlutterAppDelegate {
 override func application(
   _ application: UIApplication,
   didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
 ) -> Bool {
   GeneratedPluginRegistrant.register(with: self)
   guard let controller = self.window.rootViewController as? FlutterViewController else { return true }
   let flavorChannel = FlutterMethodChannel(name:kChannel, binaryMessenger: controller as! FlutterBinaryMessenger)
   flavorChannel.setMethodCallHandler{ (call, result) in
       if call.method == kMethodFlavor {
           let flavor = Bundle.main.object(forInfoDictionaryKey: "Flavor") as! String
           result(flavor);
       }
   }
   return super.application(application, didFinishLaunchingWithOptions: launchOptions)
 }
}

MainActivity.kt Android:

package com.example.flavors_app
import androidx.annotation.NonNull
import com.example.flavors_app.BuildConfig.FLAVOR
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
import io.flutter.plugins.GeneratedPluginRegistrant
private const val kChannel = "flavor"
private const val kMethodFlavor = "getFlavor"
class MainActivity: FlutterActivity() {
   override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
       GeneratedPluginRegistrant.registerWith(flutterEngine);
       // Method channel
       MethodChannel(flutterEngine.dartExecutor.binaryMessenger, kChannel)
           .setMethodCallHandler { call, result ->
               if (call.method == kMethodFlavor) {
                   result.success(FLAVOR);
               } else {
                   result.notImplemented()
               }
           }
   }
}

Маючи відповідні реалізації на обох платформах, повертаємось до Flutter та додамо метод, що буде звертатись до MethodChannel та діставати потрібний flavor. Але навіщо зупинятись на цьому, якщо наш метод може одразу повертати і BuildType. Звісно для простоти створимо також два енами, що опишуть основні типи збірок та лейблів.

enum BuildType { debug, release, profile }
enum Label { base, doub, fib }
const String _methodChannelName = 'flavor';
const String _methodName = 'getFlavor';
Future<(Label, BuildType)> _getLabelAndBuildTypeFromPlatform() async {
 Label label = Label.base;
 BuildType buildType = BuildType.debug;
 try {
   String? flavorString = await (const MethodChannel(_methodChannelName))
       .invokeMethod<String>(_methodName);
   if (flavorString != null) {
     if (flavorString == Label.fib.name) {
       label = Label.fib;
     } else if (flavorString == Label.doub.name) {
       label = Label.doub;
     }
     if (kProfileMode) {
       buildType = BuildType.profile;
     } else if (kReleaseMode) {
       buildType = BuildType.release;
     }
   }
 } catch (e) {
   log('Failed: ${e.toString()}', name: 'AppConfig');
   log('FAILED TO LOAD FLAVOR', name: 'AppConfig');
 }
 return (label, buildType);
}

Тепер єдине, що залишилось, це додати публічний метод, що поверне нам конкретний конфіг згідно з отриманими flavor та типом збірки:

Future<AppConfig> getAppConfig() async {
 final (label, buildType) = await _getLabelAndBuildTypeFromPlatform();
 return switch (label) {
   Label.base => BaseConfig(buildType),
   Label.doub => DoubConfig(buildType),
   Label.fib => FibConfig(buildType),
 };
}

Отже цей розділ ми закінчуємо з готовими конфігураціями на стороні Flutter-застосунку, зареєстрованими каналами для роботи взаємодії з нативними частинами коду та методами, що повернуть потрібний конфіг. Далі залишилось тільки застосувати отриманий конфіг для налаштування вигляду та поведінки наших застосунків.

Usage

Щоб використати створені конфігурації нам потрібно для початку завантажити їх у момент старту застосунку перед початком відмальовування контенту.

Для цього оновимо main() функцію та використаємо метод getAppConfig(), що ми створили раніше.

Future<void> main() async {
 WidgetsFlutterBinding.ensureInitialized();
 final appConfig = await getAppConfig();
 runApp(MyApp(appConfig: appConfig));
}

Конфіг передаємо в MyApp-віджет та використовуємо, щоб змінити тему, імʼя апки та підтримувані мови (звісно, його можна використати для будь-яких подібних налаштувань).

class MyApp extends StatelessWidget {
 final AppConfig appConfig;
 const MyApp({super.key, required this.appConfig});
 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     debugShowCheckedModeBanner: appConfig.buildType == BuildType.debug,
     title: appConfig.appName,
     theme: appConfig.theme,
     supportedLocales: appConfig.supportedLocales,
     localizationsDelegates: AppLocalizations.localizationsDelegates,
     home: MyHomePage(appConfig: appConfig),
   );
 }
}

Ну і нарешті модифікуємо нашу апку, щоб вона відповідала очікуванням залежно від конфігурації лейблу. Для прикладу використаємо простий StatefulWidget з методами для кожного лейблу. Але в повноцінному проєкті було б доцільніше вкласти конфіг в основу архітектури.

Створимо окремі методи для кожного лейблу та один загальний, що буде повертати конкретну імплементацію відносно типу конфігу:

 void _incrementCounter() {
   setState(() {
     _counter++;
   });
 }
 void _doubleCounter() {
   setState(() {
     _counter *= 2;
   });
 }
 void _fibCounter() {
   setState(() {
     final prev = _counter;
     _counter = _prevCounter + _counter;
     _prevCounter = prev;
   });
 }
 void _count() => switch (widget.appConfig) {
       DoubConfig() => _doubleCounter(),
       FibConfig() => _fibCounter(),
       BaseConfig() => _incrementCounter(),
     };

Також схожим чином реалізуємо геттери для тексту над каунтером та шляху до зображення, що буде відображено на головному екрані для кожного бренду.

String _getTitle(BuildContext context) => switch (widget.appConfig) {
 DoubConfig() => AppLocalizations.of(context)!.title_double,
 FibConfig() => AppLocalizations.of(context)!.title_fib,
 BaseConfig() => AppLocalizations.of(context)!.title_base,
};
String _imgPath() => switch (widget.appConfig) {
     FibConfig() => '${widget.appConfig.assetsPath}/img.webp',
     _ => '${widget.appConfig.assetsPath}/img.png',
   };

Після всіх вищеназваних апдейтів наш білд метод в основному апп віджеті буде виглядати так:

@override
Widget build(BuildContext context) {
 return Scaffold(
   appBar: AppBar(
     backgroundColor: Theme.of(context).colorScheme.inversePrimary,
     title: Text('${widget.appConfig.appName} Counter App'),
   ),
   body: Center(
     child: Column(
       mainAxisAlignment: MainAxisAlignment.center,
       children: <Widget>[
         SizedBox(
           height: 300.0,
           child: Image.asset(_imgPath()),
         ),
         const Spacer(),
         Text(
           _getTitle(context),
           textAlign: TextAlign.center,
         ),
         Text(
           '$_counter',
           style: Theme.of(context).textTheme.headlineMedium,
           textAlign: TextAlign.center,
         ),
         const Spacer(flex: 2),
       ],
     ),
   ),
   floatingActionButton: FloatingActionButton(
     onPressed: _count,
     child: const Icon(Icons.add),
   ),
 );
}

Перейдемо до результатів, маємо три різних застосунки:

Як можна побачити на скриншотах, нам вдалось перевизначити переклади, кольори, теми, зображення. Також було перевизначено функціональність для кожного бренду, але, на жаль, скриншотами це не покажеш.

Це лише частина лейблингового процесу. На етапі збірки застосунку ще доведеться підміняти іконку для кожного з брендів, оскільки у нас єдиний проєкт. Також довелось продублювати переклади для кожного варіанту і створити окрему логіку для вибору перекладу в залежності від бренду.

Усі ці нюанси дещо обмежують наші можливості в майбутньому та додають зайвої роботи.

Review

На мою думку, це доволі простий і зрозумілий підхід до лейблингу мобільних застосунків. Також, як великий плюс, не обовʼязково використовувати складні конструкції або наслідування для перевизначення функціональності (хоча з іншого боку це і мінус, оскільки обмежує можливість подальшої модифікації).

Загалом можна зробити висновок, що такий конфіг швидкий у налаштуванні та доволі прямолінійний, отже, недостатньо гнучкий. Тому може бути використаний або для проєкту з невеликою кількістю брендів, або на перших етапах проєкту, до прикладу, MVP-стадії.

Для інших випадків, особливо для тих проєктів, що матимуть велику кількість брендів, потрібне щось комплексніше з більшим потенціалом до розширення. І, на щастя, таке рішення існує — це Dependencies-підхід, і його ми розглянемо далі.

Dependencies labeling

У цьому випадку маються на увазі залежності на рівні модулів, а не всередині одного проєкту. Тобто ідея полягає в тому, щоб створити базовий модуль та пронаслідувати всі лейбли від нього.

Таким чином можна буде перевизначити теми, будь-які ресурси, переклади та окремі віджети. Крім того, це все гарно комбінується з buildVariants та конфігом з попереднього розділу, що в сукупності може дати найширші можливості для налаштувань і гарну структурованість.

Не бачу причин зволікати, погнали розбиратись!

Setup

У новій директорії створимо новий Flutter проєкт під назвою base, як базовий. І одразу ж додамо flavor-модуль, що матиме в собі папку з асетами для конкретного бренду, а також перевизначиними конфігами та іншими файлами всередині папки lib. Звісно туди ж додаєм pubspec.yaml для налаштування проєктних залежностей та l10n.yaml для налаштування генерації перекладів.

В flavor/pubspec.yaml додаємо залежність до base-модуля та інші більш звичні налаштування для Flutter-проєкту. Одразу можу виділити плюс такого підходу, бо можна окремо контролювати версію для кожного бренду.

name: flavor
description: "Flavor of the base app"
publish_to: 'none'
version: 1.0.0+1
environment:
 sdk: '>=3.2.4 <4.0.0'
dependencies:
 base:
   path: ../../base

Тепер потрібно додати зворотну залежність до flavor-модуля в середині base/pubspec.yaml разом з додаванням flutter_localizations та intl для підтримки різних мов, а також посилання на асети, що будуть знаходитись у flavor-модулі:

dependencies:
 flutter:
   sdk: flutter
 flavor:
   path: flavor
 flutter_localizations:
   sdk: flutter
 intl: any
flutter:
 uses-material-design: true
 generate: true
 assets:
   - flavor/assets/

У майбутньому всі бібліотеки, що будуть використовуватись в базовому проєкті чи похідних лейблах, потрібно буде додавати до базового та локальних pubspec.yaml-файлів.

Далі модифікуємо main.dart та розділяємо MyApp віджет на дві окремі сутності з контентом застосунку HomePage та конфігурацією MaterialApp. Це потрібно для того, щоб можна було за потреби перевизначити віджет з контентом застосунку пізніше. Також додаємо конфіги асетів, перекладів та тем в папку config:

Всі ці конфіги являють собою абстрактні класи з базовими налаштуваннями, що можна буде легко перевизначити для кожного окремого лейблу в майбутньому. Окрім них, я також додав use_case папку з класом BaseCount, що буде виконувати обрахунки, адже ми робимо такі ж каунтери, як в попередньому прикладі:

class BaseCount {
 int call(int counter) => counter + 1;
}

Але і це ще не все, після того, як ми створили всі базові класи та конфігурації, потрібно перевизначити їх у flavor-модулі, оскільки саме завдяки перевизначенню flavor-модуля можна буде створювати нові бренди з посиланням на базовий.

Config

Отже для початку ми, так би мовити, створюємо саме White Label (в нашому проєкті це base flavor) та пронаслідуємо всі конфіг-файли та юз-кейси без змін. Тобто всі дані залишаться ті самі, що і в базовому проєкті.

Також згенеруємо переклади для англійської та української мов з app_en.arb та app_uk.arb файлів відповідно, користуючись офіційною документацією.

Отримаємо наступну структуру файлів. Та ви напевне вже помітили, що там є action_button.dart, про який я не згадував раніше. Це саме той віджет, який ми будемо перевизначати для наших цілей в різних лейблах. Він використовується в home_page.dart та виглядає ось так:

class ActionButton extends StatelessWidget {
 final int counter;
 final void Function(int) onCounterUpdated;
 const ActionButton({
   super.key,
   required this.counter,
   required this.onCounterUpdated,
 });
 @override
 Widget build(BuildContext context) {
   return FloatingActionButton(
     onPressed: () {
       final value = FlavorCount().call(counter);
       onCounterUpdated(value);
     },
     child: const Icon(Icons.add),
   );
 }
}

У ньому використовується юзкейс FlavorCount, що наслідується від BaseCount юзкейсу та також може бути перевизначений для інших лейблів. А сам віджет ActionButton та інші конфіг-класи використовуються в HomePage-класі таким чином:

@override
Widget build(BuildContext context) {
 final localizations = FlavorLocalizations();
 final l10n = localizations.getAll(context);
 final assets = FlavorAssets();
 return Scaffold(
   appBar: AppBar(
     backgroundColor: Theme.of(context).colorScheme.inversePrimary,
     title: Text('${localizations.appName} Counter App'),
   ),
   body: Center(
     child: Column(
       mainAxisAlignment: MainAxisAlignment.center,
       children: <Widget>[
         SizedBox(
           height: 300.0,
           child: Image.asset(assets.logo),
         ),
         const Spacer(),
         Text(
           l10n.title,
           textAlign: TextAlign.center,
         ),
         Text(
           '$_counter',
           style: Theme.of(context).textTheme.headlineMedium,
           textAlign: TextAlign.center,
         ),
         const Spacer(flex: 2),
       ],
     ),
   ),
   floatingActionButton: ActionButton(
     counter: _counter,
     onCounterUpdated: (value) => setState(() => _counter = value),
   ),
 );
}

Контент цього віджета виглядає майже так само, як в попередньому прикладі з BuildVariants. З невеликими відмінностями лише у використанні ActionButton, FlavorLocalizations та FlavorAssets, що тягнуться з flavor-модуля.

І ось результат, наш базовий застосунок:

Виглядає точно так само, як і в попередньому прикладі, і це перемога!

Usage

Отже ми закінчили з підготовкою та нарешті можемо почати створення решти брендів, а саме fib та double. Для їхгього налаштування потрібно буде робити все те саме, тому розглянемо лише один з них.

Створюємо новий проєкт у тій самій папці, де створили base. Називаємо його fib, та отримуємо ось таку структуру:

Загалом ми можемо скопіювати всю структуру з base проєкту в наш новий лейбл, оскільки більшість файлів будуть ідентичні з невеликими відмінностями. До прикладу, в pubspec.yaml ми вкажемо посилання на base модуль та перевизначимо посилання на flavor модуль.

І тут важливо саме перевизначити його, оскільки нам потрібно наш base flavor модуль підмінити локальним. Виглядає це таким чином:

dependencies:
 base:
   path: ../base
dependency_overrides:
 flavor:
   path: flavor

А от в папці lib для fib модуля нам потрібно лише визначити main-функцію, яку ми викличемо з base-проєкту, оскільки вона є основною і базовою вхідною точкою в застосунок. І таким чином main.dart у нас виглядатиме максимально просто:

import 'package:base/main.dart' as base;
void main() => base.main();

Тепер залишилось тільки модифікувати локальний flavor-модуль. Тут все просто, нам потрібно пронаслідувати від наявних базових конфігурацій та перевизначити ті параметри, що відрізняються для fib-проєкту.

А саме шлях до логотипу в асетах, підтримувані переклади та заголовок головного екрану в локалізаціях, основний колір в темі.

Ось приклади, як всі ці конфіги виглядають на практиці. Головне тут звернути увагу на ту, щоб у кожному лейблі у flavor-модулі всі класи називалися однаково, це потрібно для того, щоб при перевизначенні залежностей класи могли бути взаємозамінними.

class FlavorAssets extends BaseAssets {
 @override
 String get logo => 'flavor/assets/img.webp';
}
class FlavorLocalizations extends BaseLocalizations {
 @override
 List<Locale> get supportedLocales => const <Locale>[Locale('en')];
 @override
 String get appName => 'Fib';
}
class FlavorTheme extends BaseTheme {
 @override
 Color get seed => Colors.yellow;
}

Коли з конфігураціями закінчено, переходимо до основної функціональності апки. І тут все також доволі просто, адже раніше ми вже створили юзкейс, що рахує наступне число для лічильника. Тому достатньо лише пронаслідувати його та перевизначити таким чином, щоб він рахував наступне число Фібоначчі:

class FlavorCount extends BaseCount {
 final int _previousCounter;
 FlavorCount(this._previousCounter);
 @override
 int call(int counter) => _previousCounter + counter;
}

Оскільки в новому юзкейсі нам потрібне попереднє значення лічильника, перевизначимо також і ActionButton віджет. Усе, що потрібно, це модифікувати його в StatefulWidget та зберігати в стейті локально значення лічильника і передавати його в юзкейс для правильних підрахунків:

class ActionButton extends StatefulWidget {
 final int counter;
 final void Function(int) onCounterUpdated;
 const ActionButton({
   super.key,
   required this.counter,
   required this.onCounterUpdated,
 });
 @override
 State<ActionButton> createState() => _ActionButtonState();
}
class _ActionButtonState extends State<ActionButton> {
 int _previousCounter = 1;
 @override
 Widget build(BuildContext context) {
   return FloatingActionButton(
     onPressed: () {
       final value = FlavorCount(_previousCounter).call(widget.counter);
       setState(() => _previousCounter = widget.counter);
       widget.onCounterUpdated(value);
     },
     child: const Icon(Icons.add),
   );
 }
}

Неймовірно, але це справді все, вітаю! Ми разом ще раз створили набір з трьох проєктів, кожен з яких описує окремий бренд. І виглядають вони так:

Review

Звісно, це ще не весь лейблинг, а тільки вершина айсбергу. Але потенціал у такого підходу значно вищий, ніж у попереднього, адже тут ми вже маємо можливість змінити іконку для кожного проєкту без підкладання ресурсів під час білд-процесу.

Також у нас є можливість налаштувати окремо нативну частину для кожного бренду повністю самостійно. Хоча це й мінус з іншого боку, оскільки породжує велику кількість копіпасти, коли нам знадобиться змінити щось ідентично для різних брендів, а таке буде постійно, при зміні базового проєкту.

BuildVariants vs Dependencies

У двох опціях лейблингу, що ми розглянули раніше, помітна різниця в простоті, гнучкості та кількості написаного коду. Давайте коротко розглянемо основні пункти, що потребують змін для різних брендів, та порівняємо їх:

Налаштування і підготовка

Насправді обидва способи однаково нескладні, хоча BuildVariant підхід явно потребує знань у нативній розробці. Тому Dependencies підхід на мою думку виграшний, оскільки підходить для будь-якого Flutter розробника. Крім того, він ще й вдало може комбінуватись з BuildVariant для більш широких налаштувань.

Асети

Тут цікавіше, але ось вам спойлер, на мою думку, знову ж таки, виграє Dependencies-спосіб, і ось чому: по-перше, іконку апки значно простіше змінювати, коли для кожного лейблу у нас окремий проєкт; по-друге, всі інші асети не дублюються та їх не потрібно підміняти щоразу, як збирається новий проєкт. Отже, будь-які питання з асетами вирішуються значно простіше, ніж з білд варіантами.

Локалізація

Тут великої різниці немає, оскільки я би очікував від різних брендів однакових перекладів, можливо лише з різними підтримуваними мовами. А визначити різні підтримувані мови легко незалежно від підходу до лейблингу. Єдина проблема, що може виникнути, це перевантаження проєкту файлами мов, що не використовуються, через що результуючий білд може займати трохи більше місця.

Фічі та брендована логіка

Що ж, цю тему в статті я не підіймав достатньо глибоко, але вважаю, що в кожному методі лейблингу можна реалізувати наслідування і/або щось на кшталт фабрики, яка буде повертати потрібну реалізацію для кожного бренду. Отже тут великої різниці немає, але більше писати і обовʼязково перевизначати код потрібно саме в Dependencies підході. І це одночасно як плюс, так і мінус, оскільки коду більше, але і структура значно прозоріша.

Висновки

Одразу видно, що тут не все так однозначно, як могло би здатися на перший погляд.

Але все ж я би рекомендував саме Dependencies. Оскільки цей спосіб простіший для розуміння, не потребує додаткових знань в нативній частині, має чітке розділення за брендами на окремі проєкти, не потребує додаткових налаштувань білд-процесу, підміни ресурсів і тому подібних речей, набагато простіший до розширення і, нарешті, може бути скомбінований з нативними білд варіантами.

Не зважаючи на значно більшу кількість коду та копіпасти, це однозначно виграшний варіант для будь-якої кількості брендів у вашому проєкті. Також нагадую про посилання на всі описані вище проєкти для охочих ознайомитись детальніше.

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

Сам робив white label через Environment Variables, але другий варіант дає більше можливостей для кастомізації тож друга частина матеріалу прям топ. Дякую!

не знав що хтось до сих пір використовує SchemeConfigurations та ProductFlavor якщо давно є Environment Variables у Flutter.

слушна думка, вважаю, що великої різниці для лейблингу немає, що саме використовувати з цього, лише менше коду писати з Environment Variables.

Є бажання переповзти з SchemeConfigurations на Енв підхід. Але нема розуміння кінцевого як при цьмоу менеджити apple code sign. Для кожного .енв створювати свій окремий Build Configuration?

Підписатись на коментарі