Skip to content

源码: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,
    );
  }
}

站点构建时间:2026/8/24 23:43:17