惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

宝玉的分享
宝玉的分享
Engineering at Meta
Engineering at Meta
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
博客园 - 聂微东
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
Last Week in AI
Last Week in AI
酷 壳 – CoolShell
酷 壳 – CoolShell
博客园 - 三生石上(FineUI控件)
T
Tailwind CSS Blog
Apple Machine Learning Research
Apple Machine Learning Research
Hugging Face - Blog
Hugging Face - Blog
爱范儿
爱范儿
博客园 - 司徒正美
人人都是产品经理
人人都是产品经理
Jina AI
Jina AI
博客园 - 叶小钗
雷峰网
雷峰网
罗磊的独立博客
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
博客园 - Franky
WordPress大学
WordPress大学
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
阮一峰的网络日志
阮一峰的网络日志
量子位

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания
Динамические иконки приложения на Flutter: подробная инст...
Павел Гершевич · 2026-06-25 · via Все публикации подряд на Хабре

Средний

10 мин

109

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.app

import android.content.ComponentName
import android.content.pm.PackageManager
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class 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 UIKit
import 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. Нажмите Опубликовать изменения.