AssetImage находит в манифесте ассетов все варианты пути, выбирает тот, чей коэффициент папки (2.0x, 3.0x) ближе всего к device pixel ratio экрана, и выставляет картинке scale, поэтому она занимает одинаковый логический размер на любом экране.
Раскладка на диске: assets/images/logo.png считается файлом 1x, варианты лежат в подпапках 2.0x/ и 3.0x/ под тем же именем; в pubspec.yaml указывается только основной путь или папка
Только файл 1x: на экране 3x его растягивают втрое, и он выглядит мыльным
Только экспорт 3x под обычным именем: Flutter считает его файлом 1x: без явного width он раскладывается втрое крупнее и всегда занимает полную декодированную память
Варианты поддерживают только ассеты: Image.network и Image.file автоматического выбора не получают; там нужный размер запрашиваете вы сами
Плоские иконки и иллюстрации: один SVG часто заменяет три PNG
FlutterMiddle
Изображения, видео и камера
Пайплайн изображений, ImageCache, декодирование, video_player, камера, аудио, EXIF, превью
Закрыто вопросов
0 / 21
- 0Повторить
Вопросы
Через пакет flutter_svg: SvgPicture.asset, SvgPicture.network или SvgPicture.string. Image не показывает SVG: кодеки Flutter декодируют растровые форматы (PNG, JPEG, WebP, GIF) в пиксели, а SVG представляет собой XML, который нужно разобрать в команды рисования.
Почему не встроено: полноценный SVG-рендерер (CSS, текст, фильтры, скрипты) большой, поэтому движок поставляет только растровые декодеры, а векторы оставляет пакетам
Перекраска: colorFilter: ColorFilter.mode(color, BlendMode.srcIn); старый параметр color устарел
Не каждый SVG отрисуется: анимации, большинство фильтров и стили через таблицы стилей не поддерживаются, поэтому просите дизайнеров экспортировать простые контуры (paths)
Быстрая загрузка: flutter_svg 2 построен на vector_graphics; предкомпиляция через vector_graphics_compiler переносит разбор XML из рантайма на этап сборки
Взять пакет вроде just_audio или audioplayers: создать AudioPlayer, задать источник (ассет, файл или URL), вызвать play() и вызвать dispose(), когда владелец уходит, потому что плеер держит нативный декодер и аудиофокус.
Источники: setAsset, setFilePath, setUrl; загрузка возвращает длительность, поэтому загружайте заранее в initState, а не по нажатию
Состояние UI: слушайте playerStateStream и positionStream для кнопки воспроизведения и прогресс-бара
**Не ждите play() в just_audio: его future завершается только тогда, когда воспроизведение встало на паузу или закончилось
Забытый dispose(): звук продолжает играть после закрытия экрана, а плееры копятся
Воспроизведение, которое должно пережить экран**: плееру место в сервисном объекте вне дерева виджетов, а не в State
Image.network создаёт NetworkImage (ImageProvider, то есть ключ кеша плюс загрузчик). Виджет превращает его в ImageStream; при промахе в памяти ImageCache провайдер скачивает и декодирует байты, а готовый кадр приходит через стрим.
**ImageProvider**: obtainKey возвращает ключ кеша (у NetworkImage это url и scale, но не headers); loadImage вызывается только при промахе. Создавать новый провайдер в каждом build не страшно: равные ключи попадают в одну запись
**ImageStream**: виджет подписывается на ImageInfo; одна текущая загрузка общая для всех виджетов, запросивших тот же ключ
**ImageCache**: PaintingBinding.instance.imageCache, LRU декодированных картинок в памяти, по умолчанию 1000 записей и 100 МБ
Дискового кеша нет: после холодного старта все картинки скачиваются заново; эту дыру закрывает cached_network_image
Устаревшие картинки: кеш привязан к URL или пути, а не к содержимому: если файл под тем же ключом поменялся, вызовите provider.evict() и сделайте так, чтобы виджет получил новый провайдер (новый ключ, например версию в URL); один evict смонтированный Image не перезагрузит
Лимит управляет лишь тем, что кеш держит про запас; картинку на экране удерживает сам виджет, и вытеснение её не освобождает. Декодированное фото стоит ширина × высота × 4 байта, поэтому лечится это декодированием в меньший размер через cacheWidth или ResizeImage.
Размер декодированный, а не файла: JPEG на 3 МБ размером 4000×3000 в виде bitmap занимает 48 МБ
Двадцать видимых плиток: почти гигабайт живых bitmap; система убивает процесс без всякого Dart-исключения, поэтому в отчётах о падениях нет ничего полезного
Декодируйте под размер плитки: cacheWidth: (tileWidth * devicePixelRatio).round(), и плитка в 400 пикселей стоит заметно меньше 1 МБ
**Менять maximumSizeBytes бесполезно: уменьшение лишь заставит заново декодировать при обратной прокрутке, увеличение удержит ещё больше
Ещё лучше**: запрашивать у сервера превью, чтобы уменьшилась и загрузка
cacheWidth и cacheHeight задаются в физических пикселях: на экране 3x круг в 96 логических пикселей требует 288, поэтому bitmap в 96 пикселей растягивается и мылится. А если передать оба параметра, фото декодируется ровно в 96×96 без учёта пропорций и сплющивается.
Что делают параметры: оборачивают провайдер в ResizeImage, и кодек декодирует сразу в этот размер; загрузка при этом остаётся полноразмерной
Умножайте на pixel ratio: (96 * MediaQuery.devicePixelRatioOf(context)).round()
Передавайте одно измерение: второе следует пропорциям исходника; или используйте ResizeImage с policy: ResizeImagePolicy.fit
Размер входит в ключ кеша: один URL в пяти размерах даёт пять декодированных копий, поэтому округляйте до нескольких фиксированных размеров
Прогрев положил в кеш ключ NetworkImage(url), а экран просит ResizeImage(NetworkImage(url), width: 1080): cacheWidth меняет ключ кеша, поэтому экран промахивается мимо кеша и начинает загрузку заново.
**Что делает precacheImage**: загружает провайдер в ImageCache, чтобы виджет с тем же ключом получил картинку уже на первом кадре
Используйте один провайдер для обоих мест: тот же тип и тот же размер; создайте его один раз и передайте в precacheImage и в Image(image: ...)
Проверяйте, а не гадайте: provider.obtainCacheStatus(configuration: ...) сообщает, лежит ли картинка в кеше (keepAlive), используется ли виджетами (live), грузится ли (pending) или её нет вовсе
Не блокируйте нажатие сетью: поставьте на await короткий таймаут и не прогревайте десятки картинок: они забивают кеш в 100 МБ и вытесняют те, что пользователь только что пролистал
**cached_network_image**: прогревайте через его собственный CacheManager и тот же cacheKey
Дисковый кеш: через flutter_cache_manager он сохраняет скачанные файлы в кеш-директорию приложения, и после перезапуска картинки грузятся с диска, а не из сети. Сверху по-прежнему работает ImageCache в памяти, а декодированный размер остаётся вашей заботой.
Свежесть: файл считается актуальным в течение Cache-Control: max-age из ответа (7 дней, если сервер его не прислал), затем перепроверяется, по возможности через ETag
Вытеснение: менеджер по умолчанию хранит до 200 файлов и удаляет те, что не использовались 30 дней; ленте с тысячами превью нужен собственный CacheManager(Config(...))
Ключом служит URL: подписанные URL с меняющимися query-параметрами промахиваются каждый раз, поэтому передавайте стабильный cacheKey
Оперативную память он не экономит: дисковый кеш не уменьшает bitmap; задайте memCacheWidth, а maxWidthDiskCache нужен, чтобы хранить файлы поменьше
Инвалидация: CachedNetworkImage.evictFromCache(url) освобождает кешированный файл после смены аватара, но смонтированный виджет держит равный провайдер, поэтому ещё смените URL или задайте виджету новый Key; emptyCache() при выходе из аккаунта
URL не меняется, а NetworkImage использует URL (и scale) как ключ кэша: после setState аватар получает равный провайдер, поэтому Flutter берёт старое декодированное изображение и новый файл вообще не скачивает.
Почему ничего не перезагружается: CircleAvatar и Image заново резолвят провайдер, только когда он перестал быть == старому; равный NetworkImage указывает на ту же запись в ImageCache, и ребилд перерисовывает старый битмап без запроса. Кэш живёт в памяти, поэтому после перезапуска новое фото появляется
Исправление: сервер возвращает вместе с пользователем версию (avatarVersion, метку updatedAt или хэш содержимого), а клиент добавляет её в URL: новая загрузка даёт новый ключ в ImageCache, в дисковом кэше cached_network_image и в CDN, а неизменившийся аватар по-прежнему попадает во все три кэша
Ещё лучше: сервер сам отдаёт версионированный URL (avatars/42/<hash>.jpg); такой файл никогда не меняется, поэтому его можно кэшировать на год
Что не работает: один NetworkImage(url).evict() удаляет запись из кэша, но смонтированный аватар держит равный провайдер и заново не запрашивает; ?t=${DateTime.now()} в build отключает кэширование и скачивает аватар на каждом ребилде
Как поймать: виджет-тест с локальным HttpServer, который считает запросы: после загрузки аватар должен быть запрошен ещё раз
ImagePicker().pickImage(source: ...) открывает системный выбор фото или камеру и возвращает XFile с копией во временной директории либо null, если пользователь отменил выбор. Системный пикер работает вне приложения, поэтому доступ к хранилищу для галереи не нужен.
iOS: в Info.plist нужен NSPhotoLibraryUsageDescription, плюс NSCameraUsageDescription для камеры и NSMicrophoneUsageDescription для видео; передавайте requestFullMetadata: false, если исходные метаданные не нужны, и тогда плагин не запрашивает доступ к медиатеке
Android: ни READ_MEDIA_IMAGES, ни разрешений на хранилище: системный Photo Picker отдаёт только то, что выбрал пользователь. Камера открывается через intent, поэтому CAMERA не нужен, если только он не объявлен в манифесте (часто другим плагином); тогда его сначала нужно получить
Уменьшайте при выборе: maxWidth, maxHeight и imageQuality сжимают файл нативно ещё до того, как его прочитает Dart
Файл временный: скопируйте его в свою директорию, если загрузку, возможно, придётся повторять
Потерянные результаты на Android: система может убить вашу activity, пока открыта камера; вызывайте retrieveLostData() при старте, иначе фото молча пропадёт
Камера хранит пиксели в ориентации сенсора, а поворот записывает в EXIF-тег Orientation. Flutter и галерея его применяют, а сервер, который перекодирует картинку или вырезает метаданные без учёта тега, показывает её боком. Запекайте поворот в пиксели до загрузки.
**RotatedBox не решает проблему: он меняет только отображение в вашем приложении и прячет баг
Запеките поворот**: flutter_image_compress с autoCorrectionAngle: true или bakeOrientation из пакета image, затем перекодируйте
Уберите остальное: keepExif: false заодно удаляет GPS-координаты, которые обычно не стоит загружать
HEIC: iPhone по умолчанию снимает в HEIC, а многие серверы и браузеры его не читают; конвертируйте в JPEG в том же проходе
Правило: выбранный файл считается недоверенным вводом: декодировать, исправить ориентацию, уменьшить, перекодировать, загрузить
Декодирование, ресайз и перекодирование 12-мегапиксельного фото чистым Dart-пакетом image занимают секунды работы процессора, а async-функция всё равно выполняет её на UI-изоляте: await отдаёт управление только в настоящих асинхронных паузах. Перенесите работу в нативный код или в другой изолят.
Куда уходит время: decodeImage выделяет около 48 МБ и обходит каждый пиксель; copyResize и encodeJpg проходят по пикселям ещё раз
Сначала нативный путь: flutter_image_compress использует платформенный энкодер в фоновом потоке: быстрее и вне вашего изолята
**Isolate.run для работы на стороне Dart: свой кроп, водяные знаки, фильтры; передавайте закодированные байты, а не декодированные картинки, потому что данные, отправленные в изолят, копируются
Плагины внутри изолята**: сначала вызовите BackgroundIsolateBinaryMessenger.ensureInitialized(token) с RootIsolateToken из главного изолята
Уменьшайте один раз: около 2048 px для телефона достаточно (minWidth/minHeight в flutter_image_compress ограничивают меньшую сторону); каждый лишний проход сжатия с потерями добавляет артефакты
Создать VideoPlayerController (networkUrl, asset или file) в initState, вызвать initialize(), показывать VideoPlayer(controller) внутри AspectRatio, когда value.isInitialized станет true, и вызвать dispose() в dispose.
**Контроллер является ValueNotifier<VideoPlayerValue>**: перестраивайте UI через ValueListenableBuilder по isInitialized, isPlaying, isBuffering и hasError
Пропорции: до инициализации value.aspectRatio ничего не значит, поэтому до этого момента резервируйте место плейсхолдером
Ошибки: неудачная загрузка выставляет hasError и errorDescription; спиннер, завязанный только на !isPlaying, крутится вечно
Фон: плагин ставит видео на паузу, когда приложение уходит в фон, и возобновляет при возврате; VideoPlayerOptions(allowBackgroundPlayback: true) оставляет его играть
Dispose: иначе нативный плеер держит декодер; аппаратных декодеров на устройстве всего несколько, поэтому в ленте держите живыми не больше одного-двух контроллеров
Контроллер, а с ним и нативный плеер, создаётся внутри build, поэтому каждая перестройка создаёт новый, который стартует с нуля, а у старых никто не вызывает dispose(). Ресурсы, которыми владеет виджет, должны жить в State: создаются в initState, освобождаются в dispose.
**build вызывается часто**: при каждой перестройке родителя, смене темы или MediaQuery, открытии клавиатуры; он должен быть дешёвым и без побочных эффектов
Утечка: каждый брошенный контроллер держит свой декодер и буферы и может продолжать играть звук в фоне
Мигание и перезапуск: новый контроллер ещё не инициализирован, поэтому VideoPlayer ничего не рисует, пока тот не будет готов, а потом играет с начала
**Зашитое 16 / 9**: используйте value.aspectRatio после завершения initialize()
Исправление: StatefulWidget, контроллер в initState, dispose() в dispose и didUpdateWidget, который пересоздаёт контроллер только при смене url
Три вещи: система должна знать, что вы играете аудио в фоне, должна существовать медиасессия, к которой система подцепит элементы управления, а плеер должен жить вне дерева виджетов. Во Flutter это обычно just_audio плюс audio_service.
iOS: UIBackgroundModes с audio в Info.plist и AVAudioSession в категории playback (её настраивает пакет audio_session); без них iOS приостанавливает приложение при блокировке экрана
Управление на iOS: экран блокировки читает MPNowPlayingInfoCenter и MPRemoteCommandCenter, которые audio_service заполняет из ваших MediaItem и PlaybackState
Android: foreground-сервис типа mediaPlayback (с разрешением FOREGROUND_SERVICE_MEDIA_PLAYBACK) и MediaSession; его уведомление обязательно, и именно оно держит процесс живым
Dart: AudioHandler, созданный через AudioService.init в main() до runApp; он владеет плеером, поэтому воспроизведение переживает навигацию, а Android может запустить его вообще без UI (медиакнопка, Android Auto)
Один простой плеер: just_audio_background настроит всё это за вас
На resumed код вызывает initialize() у контроллера, который сам же освободил на inactive. Освобождённый CameraController нельзя использовать повторно: initialize() бросает CameraException('Disposed CameraController'), а превью остаётся на чёрной текстуре закрытой камеры.
**Освобождать камеру на inactive правильно: плагин не обрабатывает жизненный цикл приложения сам начиная с версии 0.5, и в его README камеру отпускают именно там, чтобы её могли забрать ОС и другие приложения; баг только в том, как экран получает её обратно
Почему ошибка тихая**: dispose() помечает контроллер мёртвым навсегда, а future от initialize() в колбэке никто не ждёт, поэтому исключение попадает в лог как необработанная асинхронная ошибка, а не как заметный краш
Исправление: на resumed создать новый CameraController и подменить его через setState: CameraPreview подключается к новой текстуре и показывает пустой Container, пока isInitialized не станет true; для этого поле перестаёт быть final
То же правило в других местах: освобождённый VideoPlayerController или AnimationController, закрытый StreamController: объект заменяют новым, а не пытаются оживить
Как поймать: виджет-тест с фейковым CameraPlatform, который отправляет inactive, затем resumed через tester.binding.handleAppLifecycleStateChanged и ожидает, что будет создана вторая камера
До того, как байты попадут на телефон: CDN-сервис изображений или пайплайн загрузки готовит варианты нужных размеров, а клиент запрашивает наименьший, который покрывает его слот. cacheWidth на клиенте экономит память и время декодирования, но ни одного скачанного байта.
Только клиент: решает проблему RAM и декодирования; оригинал на 4 МБ всё равно идёт по мобильной сети ради превью в 100 точек
CDN-сервис изображений: URL вида ?w=640&fm=webp делает размер по запросу: один оригинал обслуживает все размеры, новый размер не требует переобработки, а формат можно выбирать под устройство. Это обычный выбор по умолчанию
Пайплайн загрузки: производные, сделанные один раз при загрузке, дешевле всего отдавать, но новый размер означает пересчёт всей библиотеки
Фиксированная лестница ширин: например, 160, 320, 640 и 1280; запрашивайте наименьшую, которая не меньше ширины слота × pixel ratio. Точные ширины под каждое устройство дробят кеш CDN
Передавайте размеры в API: ширина, высота и BlurHash для каждого элемента позволяют плитке заранее зарезервировать пропорции, и список не прыгает, пока грузятся картинки
Сначала посчитайте: видимые плитки × ширина × высота × 4 байта дают декодированную память на экран; лестницу выбирайте исходя из этой цифры
Адаптивный стриминг есть: дайте ему HLS-манифест, и ExoPlayer или AVPlayer сами переключают битрейт. DRM, офлайн-загрузок и выбора качества или битрейта в его API нет, и здесь вы переходите на плеерный SDK с поддержкой DRM или на собственный нативный плагин.
HLS везде, DASH только на Android: AVPlayer не играет DASH, поэтому HLS остаётся безопасным выбором по умолчанию; если нужен ещё и DASH, упакуйте сегменты CMAF (fMP4) один раз и опубликуйте оба манифеста
**formatHint**: сообщает Android, что за URL без расширения, например VideoFormat.hls
Чего нет: Widevine и FairPlay, выбора качества, ограничения битрейта для режима экономии трафика, выбора субтитров (только внешние файлы), загрузок; выбор аудиодорожки есть с video_player 2.11 (getAudioTracks(), selectAudioTrack())
Варианты: коммерческий плеерный SDK с Flutter-плагином или свой плагин поверх Media3 ExoPlayer с Widevine через DrmConfiguration и AVPlayer с AVContentKeySession для FairPlay
Отрисовка защищённого видео: аппаратно защищённые декодеры пишут в поверхности, которые Flutter не может прочитать, поэтому для L1/FairPlay обычно нужен platform view, а не Texture
Решайте заранее: проверьте требования к DRM и загрузкам до выбора плеера; замена плеера позже означает переписать весь слой воспроизведения
Подтормаживания возникают от тяжёлой работы с кадром на UI-изоляте; обрабатывайте по одному кадру вне его, остальные кадры, пришедшие тем временем, отбрасывайте.
Превью растянуто? Не оборачивайте CameraPreview в свой AspectRatio: он уже сам меняет пропорции для портрета. Чтобы заполнить экран, используйте FittedBox(fit: BoxFit.cover) с previewSize, где стороны переставлены, и обрежьте
Нет противодавления (back-pressure): кадры приходят примерно 30 раз в секунду, и каждый колбэк выполняется на UI-изоляте; держите флаг занятости и отбрасывайте кадры, пока предыдущий в обработке
Никакой конвертации пикселей в Dart: YUV в RGB на каждом кадре стоит больше бюджета кадра; запрашивайте nv21 на Android и bgra8888 на iOS и отдавайте плоскости в ML Kit, нативный код или изолят
Меньшее разрешение для анализа: детекции редко нужен ResolutionPreset.high, и каждая ступень вниз сокращает работу на кадр
Жизненный цикл: освобождайте контроллер при AppLifecycleState.inactive и создавайте заново при resumed; система отбирает камеру у приложения в фоне
По умолчанию берут Texture: нативный код рисует кадры в GPU-поверхность движка, и Flutter композитит её как обычный слой, без копирования в Dart. Platform view берут, только когда нужен сам нативный view, например для DRM, ценой лишней работы композитинга на каждом кадре.
Как работает текстура: плагин регистрирует поверхность (createSurfaceProducer() на Android, FlutterTexture, отдающий CVPixelBuffer, на iOS), а Dart получает лишь целочисленный id для Texture(textureId: ...); так работают CameraPreview и video_player
Что позволяет текстура: обрезку, трансформации, анимацию и оверлеи через CustomPaint, как у любого виджета
Чего стоит platform view: движку приходится совмещать две UI-системы: на iOS каждому Flutter-виджету поверх view нужен дополнительный оверлейный слой, а hybrid composition на Android добавляет синхронизацию потоков; обрезка, трансформации и прозрачность на нём дорогие
Когда текстура не подходит: аппаратно защищённый DRM (Widevine L1, FairPlay) декодируется в защищённую поверхность, которую нельзя прочитать, поэтому в текстуре он показывается чёрным
Ловушка: startImageStream в Image.memory на каждом кадре означает копирование, кодирование, декодирование и загрузку в GPU на каждый кадр на UI-изоляте; годится для редкого снимка, но не для показа
Скачивать одну выбранную версию каждого выпуска целым файлом, который принадлежит вам, класть его в директорию приложения, которую система не чистит и не бэкапит, и учитывать каждый файл в небольшом индексе с бюджетом в байтах и вытеснением давно не прослушанного.
Что скачивать
В video_player нет API загрузки, а офлайн-HLS (AVAssetDownloadTask, DownloadManager из Media3) требует нативной работы; простой путь: один прогрессивный MP4 или M4A на выпуск в качестве, выбранном при старте загрузки
Пишите в name.part и переименовывайте только когда размер совпал с Content-Length, иначе обрезанный файл воспроизводится как битый
Докачивайте через HTTP-запросы с Range; на iOS фоновая URLSession (например, через background_downloader) продолжает работу, пока приложение приостановлено
Где хранить
Не в кеше и не во временной директории: iOS чистит их при нехватке места; так выпуск и исчезает перед поездкой
Не в Documents на iOS: она бэкапится в iCloud, и App Review может отклонить приложения, которые бэкапят повторно скачиваемые медиа
getApplicationSupportDirectory() с исключением папки из бэкапа: isExcludedFromBackup на iOS (выставляется через platform channel или пакет), правила бэкапа в манифесте на Android
Как ограничить место
Одна строка индекса на файл: id, путь, байты, когда последний раз прослушан, закреплён ли
После каждой загрузки вытесняйте давно не прослушанные незакреплённые элементы, пока сумма не уложится в бюджет, и показывайте этот бюджет в настройках
При старте сверяйте индекс с папкой: удаляйте файлы-сироты и строки, у которых файла нет