Appearance
源码:labs/flutter/host/lib/topics/screen_adaptation/asset_variant_demo/asset_variant_demo_page.dart
- 原始路径:
labs/flutter/host/lib/topics/screen_adaptation/asset_variant_demo/asset_variant_demo_page.dart - 类型:
dart
dart
import 'package:flutter/material.dart';
/// 图片资源变体实验:验证「Flutter 按 devicePixelRatio 找 1.0x/2.0x/3.0x 变体、
/// 缺失不缩放」。
///
/// 素材(程序生成的网格 + 圆 + 角标色,见 README):
/// assets/asset_variant_demo/logo_1x_only.png 48px,无任何变体(永远 1.0x)
/// assets/asset_variant_demo/logo.png 48px(1.0x)
/// assets/asset_variant_demo/2.0x/logo.png 96px(2.0x)
/// assets/asset_variant_demo/3.0x/logo.png 144px(3.0x)
/// 角标色 = 命中的变体:1x 蓝 / 2x 绿 / 3x 橙。
class AssetVariantDemoPage extends StatefulWidget {
const AssetVariantDemoPage({super.key});
static const routeName = '/asset_variant_demo';
@override
State<AssetVariantDemoPage> createState() => _AssetVariantDemoPageState();
}
class _AssetVariantDemoPageState extends State<AssetVariantDemoPage> {
static const String _asset1xOnly = 'assets/asset_variant_demo/logo_1x_only.png';
static const String _assetVariants = 'assets/asset_variant_demo/logo.png';
static const double _expectedLogical = 120; // 设计期望的逻辑尺寸
double _simulatedDpr = 3.0;
String get _chosenVariantLabel {
const scales = [1.0, 2.0, 3.0];
for (final scale in scales) {
if (scale >= _simulatedDpr) {
return '${scale.toStringAsFixed(1)}x';
}
}
return '3.0x(无 ≥ 模拟 dpr 的变体,回退最高档)';
}
@override
Widget build(BuildContext context) {
final realDpr = MediaQuery.of(context).devicePixelRatio;
final textTheme = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: const Text('图片资源变体 Demo')),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
'验证「按 devicePixelRatio 找 1.0x/2.0x/3.0x 变体、缺失不缩放」。'
'素材角标色 = 命中的变体(1x 蓝 / 2x 绿 / 3x 橙)。',
style: textTheme.bodyMedium,
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('真实 devicePixelRatio:${realDpr.toStringAsFixed(2)}')),
const Chip(label: Text('素材:48 / 96 / 144px')),
Chip(label: Text('期望逻辑尺寸:${_expectedLogical.toStringAsFixed(0)}dp')),
],
),
),
),
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('模拟 devicePixelRatio(观察变体选择规则)', style: textTheme.titleMedium),
const SizedBox(height: 4),
Text(
'规则:返回第一张 scale ≥ 模拟 dpr 的变体;若一张都没有则回退最高档。',
style: textTheme.bodySmall,
),
Slider(
value: _simulatedDpr,
min: 1.0,
max: 4.0,
divisions: 24,
label: _simulatedDpr.toStringAsFixed(2),
onChanged: (value) => setState(() => _simulatedDpr = value),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
Chip(label: Text('模拟 dpr:${_simulatedDpr.toStringAsFixed(2)}')),
Chip(label: Text('带变体素材 → 命中:$_chosenVariantLabel')),
],
),
],
),
),
),
const SizedBox(height: 16),
Text('真机实际表现(真实 devicePixelRatio = ${realDpr.toStringAsFixed(2)})',
style: textTheme.titleMedium),
const SizedBox(height: 8),
_DemoCard(
title: 'A · 只有 1x · 自然尺寸(固有逻辑尺寸 = 48dp)',
subtitle:
'无约束渲染:固有逻辑尺寸 = 像素 ÷ scale = 48dp(绝对不缩小);'
'但在真实 ${realDpr.toStringAsFixed(1)}x 屏上需要 ${(48 * realDpr).toStringAsFixed(0)} 物理像素点阵,'
'48px 原图被 GPU 强制放大拉满,肉眼可见明显偏软 / 模糊。',
info: _ImageInfoLine(asset: _asset1xOnly, dpr: realDpr),
child: SizedBox.square(
dimension: _expectedLogical,
child: Image.asset(_asset1xOnly, fit: BoxFit.scaleDown),
),
),
const SizedBox(height: 12),
_DemoCard(
title: 'B · 只有 1x · 强制 ${_expectedLogical.toStringAsFixed(0)}dp',
subtitle:
'布局强制 120dp:物理坑位 ${(120 * realDpr).toStringAsFixed(0)}px 只有 48px 源像素,'
'拉满 → 放大 ${(120 * realDpr / 48).toStringAsFixed(1)}×,严重模糊 / 锯齿。',
info: _ImageInfoLine(asset: _asset1xOnly, dpr: realDpr),
child: SizedBox.square(
dimension: _expectedLogical,
child: Image.asset(_asset1xOnly, fit: BoxFit.fill),
),
),
const SizedBox(height: 12),
_DemoCard(
title: 'C · 带 1x/2x/3x · 自然尺寸',
subtitle:
'dpr ≥ 3 命中 3.0x(144px),固有尺寸 144 / 3.0 = 48dp,物理 144px 点对点 1:1 绝对清晰!'
'注意:若强制 120dp,144px 也不够(物理坑 ${(120 * realDpr).toStringAsFixed(0)}px),'
'照样会糊——变体只保证设计逻辑尺寸(48dp)下的锐度,救不了超出尺寸的放大。',
info: _ImageInfoLine(asset: _assetVariants, dpr: realDpr),
child: SizedBox.square(
dimension: _expectedLogical,
child: Image.asset(_assetVariants, fit: BoxFit.scaleDown),
),
),
const SizedBox(height: 16),
Text('滑块模拟:解码像素与选中变体(内存 = 解码像素,不被 density 预放大)',
style: textTheme.titleMedium),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
_InfoRow(
label: '只有 1x',
info: _ImageInfoLine(asset: _asset1xOnly, dpr: _simulatedDpr),
),
const Divider(height: 24),
_InfoRow(
label: '带 1x/2x/3x',
info: _ImageInfoLine(asset: _assetVariants, dpr: _simulatedDpr),
),
],
),
),
),
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('观察重点', style: textTheme.titleMedium),
const SizedBox(height: 8),
const Text(
'1. 只有 1x 的图:把滑块从 1.0 拖到 4.0,解码像素恒为 48×48、scale 恒为 1.0——'
'Flutter 不在内存里按 density 自动预放大(对比 Android 低桶会被解码放大 ×9)。这就是「内存不预放大」。',
),
const Text(
'2. 自然尺寸渲染:固有逻辑尺寸 = 原图 48px ÷ scale 1.0 = 48dp(渲染尺寸绝对不缩小)。'
'但在 3x 屏上需要 144px 物理点阵,48px 被 GPU 强行 3x 拉大,显明显偏软发虚。',
),
const Text(
'3. 强制 120dp:48px 源被拉到物理 360px(3x 屏,7.5×),肉眼可见极度马赛克 / 锯齿。',
),
const Text(
'4. 带变体:dpr ≥ 3 命中 3.0x(144px),固有尺寸 144 / 3.0 = 48dp,物理 144px 点对点 1:1 绝对清晰;'
'但强制超过设计尺寸的放大,变体也救不了。',
),
],
),
),
),
],
),
);
}
}
class _DemoCard extends StatelessWidget {
const _DemoCard({
required this.title,
required this.subtitle,
required this.child,
this.info,
});
final String title;
final String subtitle;
final Widget child;
final Widget? info;
@override
Widget build(BuildContext context) {
final textTheme = Theme.of(context).textTheme;
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: textTheme.titleSmall),
const SizedBox(height: 4),
Text(subtitle, style: textTheme.bodySmall),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blueGrey.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.blueGrey.shade100),
),
child: Center(child: child),
),
if (info != null) ...[
const SizedBox(height: 8),
info!,
],
],
),
),
);
}
}
class _InfoRow extends StatelessWidget {
const _InfoRow({required this.label, required this.info});
final String label;
final Widget info;
@override
Widget build(BuildContext context) {
return Row(
children: [
SizedBox(width: 120, child: Text(label, style: Theme.of(context).textTheme.bodyMedium)),
Expanded(child: info),
],
);
}
}
/// 以指定 devicePixelRatio 解析 AssetImage,实时打印「解码像素 / scale / 逻辑尺寸」。
class _ImageInfoLine extends StatefulWidget {
const _ImageInfoLine({required this.asset, required this.dpr});
final String asset;
final double dpr;
@override
State<_ImageInfoLine> createState() => _ImageInfoLineState();
}
class _ImageInfoLineState extends State<_ImageInfoLine> {
ImageInfo? _info;
ImageStream? _stream;
ImageStreamListener? _listener;
Object? _error;
@override
void initState() {
super.initState();
_resolve();
}
@override
void didUpdateWidget(_ImageInfoLine oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.asset != widget.asset || oldWidget.dpr != widget.dpr) {
_dispose();
_resolve();
}
}
void _resolve() {
final provider = AssetImage(widget.asset);
_stream = provider.resolve(ImageConfiguration(devicePixelRatio: widget.dpr));
_listener = ImageStreamListener(
(info, _) {
if (!mounted) return;
setState(() {
_info?.dispose();
_info = info;
_error = null;
});
},
onError: (error, stackTrace) {
if (!mounted) return;
setState(() => _error = error);
},
);
_stream!.addListener(_listener!);
}
void _dispose() {
if (_stream != null && _listener != null) {
_stream!.removeListener(_listener!);
}
_stream = null;
_listener = null;
_info?.dispose();
_info = null;
}
@override
void dispose() {
_dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (_error != null) {
return Text(
'解析失败:$_error',
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.error),
);
}
final info = _info;
if (info == null) {
return Text('解码中…', style: theme.textTheme.bodySmall);
}
final logical = info.image.width / info.scale;
return Text(
'解码 ${info.image.width}×${info.image.height} · scale ${info.scale.toStringAsFixed(1)} · '
'逻辑 ${logical.toStringAsFixed(1)}dp',
style: theme.textTheme.bodySmall,
);
}
}