Динамические иконки приложения на Flutter: подробная инструкция для ручного выбора и обновлений по воздуху

от автора

Hola, Amigos! На связи Павел Гершевич, Mobile Team Lead агентства продуктовой разработки Amiga и соавтор книги «Основы Flutter». Мы с командой подготовили для вас перевод статьи о том, как можно кастомизировать иконку приложения динамически. Всем приятного чтения!

Наличие приложения на домашнем экране устройства очень ценно для бизнеса. Когда у пользователей есть возможность самим настраивать иконку вручную, это дарит им приятное чувство индивидуальности. Вместе с этим, возможность менять иконку приложения удаленно позволяет внедрять тематические изменения. Например, на различные праздники, ребрендинг в темную тему или временную картинку с маркетинговой компанией. И все это без публикации новой сборки в App Store или Google Play Store.

В этой подробной инструкции мы реализуем систему для динамических иконок в Flutter, которая работает в двух режимах, не полагаясь на багованные или устаревшие сторонние библиотеки. Мы создадим собственные MethodChannels на Kotlin и Swift с поддержкой двух ключевых функций:

  1. Ручной выбор: предоставление пользователям возможности самим выбрать иконку по их предпочтениям в настройках UI.

  2. Обновление по воздуху: переопределение или синхронизация иконки автоматически через Firebase Remote Config.

Как это устроено

Перед написанием кода, важно понять как отличаются платформы, для которых мы это делаем. Так как Flutter работает в едином контексте выполнения, это означает, что мы должны использовать платформенные каналы для запуска нативных API операционной системы.

Android: Component Aliases

Android обрабатывает иконки приложения статично при помощи AndroidManifest.xml. Чтобы обойти эти ограничения, мы объявляем несколько тегов <activity-alias>, которые указывают на MainActivity. Переключая состояние этих alias при помощи нативного PackageManager, домашний экран устройства обновляется для отображения иконки, добавленной к активному alias.

⚠️ Побочный эффект на Android:

Включение или выключение activity alias очищает текущий стек приложений и кратковременно отключает фоновое состояние приложения, чтобы переключить профиль запуска.

iOS: API альтернативных иконок

Apple предоставляет нативный способ через UIApplication.shared.setAlternateIconName(). В отличии от стандартных графических пакетов, альтернативные иконки не могут быть добавлены в компилируемый каталог ассетов (Assets.xcassets). Вместо этого, они должны быть добавлены в корневую папку приложения как отдельные файлы и прописаны вручную внутри настроек приложения (Info.plist).

Шаг 1: Подготавливаем нативную инфраструктуру

1. Подготовка MethodChannel в Flutter

Создайте удобный вспомогательный класс в вашем Dart проекте для того, чтобы подключиться к каналу связи с нативной системой.

import 'package:flutter/services.dart';class DynamicIconService { static const MethodChannel _channel = MethodChannel('com.dynamicicon.app/dynamic_icon'); /// Необходимо добавить нативный код для смены активной иконки приложения. static Future<void> changeIcon(String iconName) async {   try {     await _channel.invokeMethod('setIcon', {'iconName': iconName});   } on PlatformException catch (e) {     print("Failed to change application launcher profile: '${e.message}'.");   } }}

2. Интеграция Android (AndroidManifest.xml и Kotlin)

Сначала, добавьте альтернативные графические ассеты в ваши папки с ресурсами:

  • android/app/src/main/res/mipmap-hdpi/ic_launcher_dark.png

  • android/app/src/main/res/mipmap-hdpi/ic_launcher_festive.png

Далее, отредактируйте android/app/src/main/AndroidManifest.xml — поставьте параметру android:enabled у .MainActivity значение false. Так мы передадим все хуки изначальной системы нашим alias.

<manifest xmlns:android="http://schemas.android.com/apk/res/android">   <application       android:label="Dynamic App"       android:name="${applicationName}">             <!-- Base Target Activity Framework (Deactivated) -->       <activity           android:name=".MainActivity"           android:exported="true"           android:enabled="false"           android:launchMode="singleTop"           android:theme="@style/LaunchTheme">           <meta-data               android:name="io.flutter.embedding.android.NormalTheme"               android:resource="@style/NormalTheme" />       </activity>       <!-- Default Application Icon Alias -->       <activity-alias           android:name=".DefaultIcon"           android:enabled="true"           android:exported="true"           android:icon="@mipmap/ic_launcher"           android:targetActivity=".MainActivity">           <intent-filter>               <action android:name="android.intent.action.MAIN"/>               <category android:name="android.intent.category.LAUNCHER"/>           </intent-filter>       </activity-alias>       <!-- Dark Variant Icon Alias -->       <activity-alias           android:name=".DarkIcon"           android:enabled="false"           android:exported="true"           android:icon="@mipmap/ic_launcher_dark"           android:targetActivity=".MainActivity">           <intent-filter>               <action android:name="android.intent.action.MAIN"/>               <category android:name="android.intent.category.LAUNCHER"/>           </intent-filter>       </activity-alias>       <!-- Festive Variant Icon Alias -->       <activity-alias           android:name=".FestiveIcon"           android:enabled="false"           android:exported="true"           android:icon="@mipmap/ic_launcher_festive"           android:targetActivity=".MainActivity">           <intent-filter>               <action android:name="android.intent.action.MAIN"/>               <category android:name="android.intent.category.LAUNCHER"/>           </intent-filter>       </activity-alias>   </application></manifest>

Откройте файл MainActivity.kt для получения методов из MethodChannel и запуска цикла смены состояния:

package com.dynamicicon.appimport android.content.ComponentNameimport android.content.pm.PackageManagerimport io.flutter.embedding.android.FlutterActivityimport io.flutter.embedding.engine.FlutterEngineimport io.flutter.plugin.common.MethodChannelclass MainActivity: FlutterActivity() {   private val CHANNEL = "com.dynamicicon.app/dynamic_icon"   private val packageNamespace = "com.dynamicicon.app"   private val aliasRegistry = listOf("DefaultIcon", "DarkIcon", "FestiveIcon")   override fun configureFlutterEngine(flutterEngine: FlutterEngine) {       super.configureFlutterEngine(flutterEngine)             MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL).setMethodCallHandler { call, result ->           if (call.method == "setIcon") {               val targetIcon = call.argument<String>("iconName")               if (targetIcon != null && aliasRegistry.contains(targetIcon)) {                   toggleLauncherComponent(targetIcon)                   result.success(null)               } else {                   result.error("ALIAS_NOT_FOUND", "The target alias mapping configuration is missing.", null)               }           } else {               result.notImplemented()           }       }   }   private fun toggleLauncherComponent(targetAlias: String) {       val pm = applicationContext.packageManager             for (alias in aliasRegistry) {           val componentState = if (alias == targetAlias) {               PackageManager.COMPONENT_ENABLED_STATE_ENABLED           } else {               PackageManager.COMPONENT_ENABLED_STATE_DISABLED           }                     pm.setComponentEnabledSetting(               ComponentName(applicationContext, "$packageNamespace.$alias"),               componentState,               PackageManager.DONT_KILL_APP           )       }   }}

3. Интеграция iOS (Info.plist и Swift)

Положите файлы ваших альтернативных иконок прямо в папку ios/Runner/ в виде отдельных исходников (например, ic_launcher_dart@2x.png, ic_launcher_festive@3x.png).

Добавьте эти ключи в ios/Runner/Info.plist:

<key>CFBundleIcons</key><dict>   <key>CFBundlePrimaryIcon</key>   <dict>       <key>CFBundleIconFiles</key>       <array>           <string>AppIcon</string>       </array>   </dict>   <key>CFBundleAlternateIcons</key>   <dict>       <key>DarkIcon</key>       <dict>           <key>CFBundleIconFiles</key>           <array>               <string>ic_launcher_dark</string>           </array>           <key>UIPrerenderedIcon</key>           <false/>       </dict>       <key>FestiveIcon</key>       <dict>           <key>CFBundleIconFiles</key>           <array>               <string>ic_launcher_festive</string>           </array>           <key>UIPrerenderedIcon</key>           <false/>       </dict>   </dict></dict>

Откройте ios/Runner/AppDelegate.swift и настройте логику MethodChannel:

import UIKitimport Flutter@UIApplicationMain@objc class AppDelegate: FlutterAppDelegate { override func application(   _ application: UIApplication,   didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool {     let controller : FlutterViewController = window?.rootViewController as! FlutterViewController   let iconChannel = FlutterMethodChannel(name: "com.dynamicicon.app/dynamic_icon",                                          binaryMessenger: controller.binaryMessenger)     iconChannel.setMethodCallHandler({     (call: FlutterMethodCall, result: @escaping FlutterResult) -> Void in     guard call.method == "setIcon" else {         result(FlutterMethodNotImplemented)         return     }         if let arguments = call.arguments as? [String: Any],        let iconName = arguments["iconName"] as? String {         self.updateAlternateIcon(named: iconName, response: result)     } else {         result(FlutterError(code: "BAD_ARGS", message: "Arguments malformed", details: nil))     }   })   GeneratedPluginRegistrant.register(with: self)   return super.application(application, didFinishLaunchingWithOptions: launchOptions) } private func updateAlternateIcon(named targetName: String, response: @escaping FlutterResult) {     if #available(iOS 10.3, *) {         let systemKey = (targetName == "DefaultIcon") ? nil : targetName                 UIApplication.shared.setAlternateIconName(systemKey) { error in             if let error = error {                 response(FlutterError(code: "IOS_ERROR", message: error.localizedDescription, details: nil))             } else {                 response(null)             }         }     } else {         response(FlutterError(code: "UNSUPPORTED_OS", message: "Minimum OS layout required", details: nil))     } }}

Шаг 2: Добавим логику управления иконками

Теперь, когда наш нативный код готов, нам нужно сбалансировать ручной выбор при помощи обновлений через remote config.

Для того, чтобы решить эту проблему быстро, мы установим бизнес-правило: Firebase Remote Config будет работать как глобальное переопределение. Если ключ из remote config опубликован через облачную админку, то он переписывает то, что пользователь установил вручную. Если облачная конфигурация не задана или возвращает DefaultIcon, то приложение должно использовать ручной выбор, который пользователь сделал внутри приложения.

Сначала, добавьте зависимости в ваш pubspec.yaml:

dependencies: flutter:   sdk: flutter firebase_core: ^3.0.0 firebase_remote_config: ^5.0.0 shared_preferences: ^2.2.0

Механизм интеграции двойного управления

Создайте файл app_icon_manager.dart, чтобы четко оркестрировать оба источника данных:

import 'package:firebase_remote_config/firebase_remote_config.dart';import 'package:shared_preferences/shared_preferences.dart';import 'dynamic_icon_service.dart';class AppIconManager { static const String _remoteOverrideKey = 'cloud_icon_override'; static const String _userPrefKey = 'user_selected_icon'; static const String _activeCachedKey = 'currently_applied_icon'; /// Режим 1: Работает вручную, когда пользователь нажимает на иконку в настройках приложения static Future<void> updateUserIconPreference(String targetIconName) async {   final SharedPreferences prefs = await SharedPreferences.getInstance();   await prefs.setString(_userPrefKey, targetIconName);     // Проверка на блокирования облаком перед обновлением   final FirebaseRemoteConfig remoteConfig = FirebaseRemoteConfig.instance;   final String cloudOverride = remoteConfig.getString(_remoteOverrideKey);   if (cloudOverride == 'DefaultIcon' || cloudOverride.isEmpty) {     await _applyIconSafetyGuard(targetIconName);   } else {     print("User setting cached, but blocked by active Cloud Override: $cloudOverride");   } } /// Режим 2: Работает автоматически при запуске приложения для того, чтобы получить правила по воздуху static Future<void> syncCloudConfiguration() async {   final FirebaseRemoteConfig remoteConfig = FirebaseRemoteConfig.instance;   try {     await remoteConfig.setDefaults(<String, dynamic>{_remoteOverrideKey: 'DefaultIcon'});     await remoteConfig.setConfigSettings(RemoteConfigSettings(       fetchTimeout: const Duration(seconds: 10),       minimumFetchInterval: const Duration(hours: 2),     ));     bool updated = await remoteConfig.fetchAndActivate();     if (!updated) return;     final String cloudOverrideValue = remoteConfig.getString(_remoteOverrideKey);     final SharedPreferences prefs = await SharedPreferences.getInstance();     if (cloudOverrideValue != 'DefaultIcon' && cloudOverrideValue.isNotEmpty) {       // Облачные данные перезаписывают данные пользователя       await _applyIconSafetyGuard(cloudOverrideValue);     } else {       // Fallback на выбор пользователя       final String userPreference = prefs.getString(_userPrefKey) ?? 'DefaultIcon';       await _applyIconSafetyGuard(userPreference);     }   } catch (e) {     print('Failed to synchronize Firebase Remote Config layer: $e');   } } /// Проверка и запуск MethodChannel если иконка действительно изменена static Future<void> _applyIconSafetyGuard(String targetValue) async {   final SharedPreferences prefs = await SharedPreferences.getInstance();   final String currentlyActive = prefs.getString(_activeCachedKey) ?? 'DefaultIcon';   // Защита от лишних действий   if (currentlyActive == targetValue) return;   print('Modifying launcher layout state from $currentlyActive to $targetValue');   await DynamicIconService.changeIcon(targetValue);   await prefs.setString(_activeCachedKey, targetValue); }}

Шаг 3: Добавление на UI слой и запуск жизненного цикла приложения

Теперь, инициализируйте наш слой синхронизации внутри метода main() вашего приложения и сделайте интерфейс быстрого выбора.

import 'package:firebase_core/firebase_core.dart';import 'package:flutter/material.dart';import 'app_icon_manager.dart';void main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(); // Запуск при старте приложения await AppIconManager.syncCloudConfiguration(); runApp(const MyApp());}class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) {   return const MaterialApp(     home: SettingsIconScreen(),   ); }}class SettingsIconScreen extends StatelessWidget { const SettingsIconScreen({super.key}); @override Widget build(BuildContext context) {   return Scaffold(     appBar: AppBar(title: const Text('Customize App Icon')),     body: Padding(       padding: const EdgeInsets.all(16.0),       child: Column(         crossAxisAlignment: CrossAxisAlignment.start,         children: [           const Text(             'Select Your Preference:',             style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),           ),           const SizedBox(height: 12),           ListTile(             leading: const Icon(Icons.phone_android),             title: const Text('Default Classic Blue'),             onTap: () => AppIconManager.updateUserIconPreference('DefaultIcon'),           ),           ListTile(             leading: const Icon(Icons.dark_mode),             title: const Text('Premium Minimalist Dark'),             onTap: () => AppIconManager.updateUserIconPreference('DarkIcon'),           ),           ListTile(             leading: const Icon(Icons.celebration),             title: const Text('Festive Celebration Theme'),             onTap: () => AppIconManager.updateUserIconPreference('FestiveIcon'),           ),         ],       ),     ),   ); }}

Заключение. Управление из облака

Наше приложение полностью готово к двойному управлению! Для того, чтобы запустить глобальное обновление по воздуху:

  1. Откройте Консоль Firebase и перейдите к Remote Config.

  2. Создайте параметр cloud_icon_override.

  3. Поставьте ему значение FestiveIcon (или оставьте DefaultIcon, если хотите учитывать выбор иконки пользователям).

  4. Нажмите Опубликовать изменения.

ссылка на оригинал статьи https://habr.com/ru/articles/1051626/