Flutter漫画阅读器 - 完整开发教程

项目简介

这是一个使用Flutter开发的漫画阅读器应用,支持图片浏览和缩放功能,提供流畅的翻页体验和章节管理。应用采用模拟内容的方式,无需额外依赖包,适合学习Flutter手势交互和图片处理。
运行效果图
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

核心特性

  • 📚 漫画书架:网格展示、添加、删除漫画
  • 📖 流畅阅读:垂直/水平翻页模式切换
  • 🔍 图片缩放:双指缩放0.5x-4x
  • 📑 章节管理:快速切换章节
  • 📊 阅读进度:自动保存和恢复
  • 📜 阅读历史:记录阅读轨迹
  • 🎨 沉浸体验:全屏阅读、点击切换菜单
  • 💾 数据持久化:使用SharedPreferences

技术栈

  • Flutter 3.6+
  • Dart 3.0+
  • shared_preferences: 数据持久化
  • InteractiveViewer: 图片缩放
  • PageView: 翻页效果

项目架构

漫画阅读器

书架页面

阅读历史页面

漫画网格

添加漫画

阅读器页面

图片缩放

翻页控制

章节切换

进度管理

数据模型设计

ComicInfo - 漫画信息模型

class ComicInfo {
  final String id;              // 唯一标识
  final String title;           // 漫画标题
  final String author;          // 作者
  final String cover;           // 封面(使用emoji)
  final int totalChapters;      // 总章节数
  final DateTime addedTime;     // 添加时间
  int currentChapter;           // 当前章节
  int currentPage;              // 当前页码
  double progress;              // 阅读进度(0-1)
}

ReadingHistory - 阅读历史模型

class ReadingHistory {
  final String comicId;         // 漫画ID
  final DateTime readTime;      // 阅读时间
  final int chapter;            // 章节
  final int page;               // 页码
}

核心功能实现

1. 书架网格布局

使用GridView展示漫画封面:

GridView.builder(
  padding: const EdgeInsets.all(16),
  gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
    crossAxisCount: 3,           // 每行3本
    childAspectRatio: 0.7,       // 宽高比
    crossAxisSpacing: 12,        // 横向间距
    mainAxisSpacing: 12,         // 纵向间距
  ),
  itemCount: comics.length,
  itemBuilder: (context, index) {
    return _buildComicCard(comics[index]);
  },
)

2. InteractiveViewer图片缩放

InteractiveViewer是Flutter内置的交互式查看器,支持缩放和平移:

Widget _buildComicPage(int pageIndex) {
  return InteractiveViewer(
    minScale: 0.5,                // 最小缩放0.5倍
    maxScale: 4.0,                // 最大缩放4倍
    child: Center(
      child: Container(
        // 漫画内容
      ),
    ),
  );
}

InteractiveViewer特性

  • 双指捏合缩放
  • 单指拖动平移
  • 双击缩放(可选)
  • 边界限制
  • 惯性滚动

缩放手势

  • 双指捏合:缩小
  • 双指分开:放大
  • 双击:快速缩放到2倍或恢复

3. PageView翻页实现

支持垂直和水平两种翻页模式:

class _ComicReaderPageState extends State<ComicReaderPage> {
  late PageController _pageController;
  bool _isVerticalMode = true;  // 垂直滚动模式
  
  
  Widget build(BuildContext context) {
    return PageView.builder(
      controller: _pageController,
      scrollDirection: _isVerticalMode ? Axis.vertical : Axis.horizontal,
      onPageChanged: (page) {
        setState(() {
          _currentPage = page;
        });
        widget.onProgressUpdate(widget.comic.id, _currentChapter, _currentPage);
      },
      itemCount: _pagesPerChapter,
      itemBuilder: (context, index) {
        return _buildComicPage(index);
      },
    );
  }
}

翻页模式切换

IconButton(
  icon: Icon(
    _isVerticalMode ? Icons.swap_horiz : Icons.swap_vert,
  ),
  onPressed: () {
    setState(() {
      _isVerticalMode = !_isVerticalMode;
    });
  },
)

4. 章节管理

实现章节列表和快速切换:

void _showChapterList() {
  showModalBottomSheet(
    context: context,
    builder: (context) => Container(
      padding: const EdgeInsets.all(20),
      child: Column(
        children: [
          const Text('选择章节', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
          const SizedBox(height: 16),
          Expanded(
            child: GridView.builder(
              gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
                crossAxisCount: 4,
                childAspectRatio: 2,
                crossAxisSpacing: 8,
                mainAxisSpacing: 8,
              ),
              itemCount: widget.comic.totalChapters,
              itemBuilder: (context, index) {
                final isCurrentChapter = index == _currentChapter;
                return InkWell(
                  onTap: () {
                    setState(() {
                      _currentChapter = index;
                      _currentPage = 0;
                    });
                    _pageController.jumpToPage(0);
                    Navigator.pop(context);
                  },
                  child: Container(
                    decoration: BoxDecoration(
                      color: isCurrentChapter ? Colors.deepOrange : Colors.grey.shade200,
                      borderRadius: BorderRadius.circular(8),
                    ),
                    child: Center(
                      child: Text('第${index + 1}话'),
                    ),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    ),
  );
}

5. 章节切换功能

void _nextChapter() {
  if (_currentChapter < widget.comic.totalChapters - 1) {
    setState(() {
      _currentChapter++;
      _currentPage = 0;
    });
    _pageController.jumpToPage(0);
    widget.onProgressUpdate(widget.comic.id, _currentChapter, _currentPage);
  } else {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('已经是最后一话了')),
    );
  }
}

void _previousChapter() {
  if (_currentChapter > 0) {
    setState(() {
      _currentChapter--;
      _currentPage = 0;
    });
    _pageController.jumpToPage(0);
    widget.onProgressUpdate(widget.comic.id, _currentChapter, _currentPage);
  } else {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('已经是第一话了')),
    );
  }
}

6. 阅读菜单设计

点击屏幕切换菜单显示/隐藏:

bool _showMenu = false;

void _toggleMenu() {
  setState(() {
    _showMenu = !_showMenu;
  });
}

// 在PageView上添加手势检测
GestureDetector(
  onTap: _toggleMenu,
  child: PageView.builder(
    // ...
  ),
)

顶部菜单

if (_showMenu)
  Positioned(
    top: 0,
    left: 0,
    right: 0,
    child: Container(
      color: Colors.black87,
      child: SafeArea(
        bottom: false,
        child: AppBar(
          backgroundColor: Colors.transparent,
          title: Text('${widget.comic.title} - 第${_currentChapter + 1}话'),
          actions: [
            IconButton(
              icon: Icon(_isVerticalMode ? Icons.swap_horiz : Icons.swap_vert),
              onPressed: () {
                setState(() {
                  _isVerticalMode = !_isVerticalMode;
                });
              },
            ),
          ],
        ),
      ),
    ),
  )

底部菜单

if (_showMenu)
  Positioned(
    bottom: 0,
    left: 0,
    right: 0,
    child: Container(
      color: Colors.black87,
      child: SafeArea(
        top: false,
        child: Column(
          children: [
            // 进度条
            Slider(
              value: _currentPage.toDouble(),
              max: (_pagesPerChapter - 1).toDouble(),
              onChanged: (value) => _goToPage(value.toInt()),
            ),
            // 功能按钮
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceAround,
              children: [
                _buildMenuButton(Icons.skip_previous, '上一话', _previousChapter),
                _buildMenuButton(Icons.list, '目录', _showChapterList),
                _buildMenuButton(Icons.skip_next, '下一话', _nextChapter),
              ],
            ),
          ],
        ),
      ),
    ),
  )

7. 进度保存和恢复

void _updateComicProgress(String id, int chapter, int page) {
  final comic = comics.firstWhere((c) => c.id == id);
  comic.currentChapter = chapter;
  comic.currentPage = page;
  comic.progress = chapter / comic.totalChapters;
  _saveData();
}

// 在阅读器初始化时恢复进度

void initState() {
  super.initState();
  _currentChapter = widget.comic.currentChapter;
  _currentPage = widget.comic.currentPage;
  _pageController = PageController(initialPage: _currentPage);
}

// 在页面销毁时保存进度

void dispose() {
  _pageController.dispose();
  widget.onProgressUpdate(widget.comic.id, _currentChapter, _currentPage);
  super.dispose();
}

8. 漫画页面渲染

创建模拟的漫画页面内容:

Widget _buildComicPage(int pageIndex) {
  return InteractiveViewer(
    minScale: 0.5,
    maxScale: 4.0,
    child: Center(
      child: Container(
        width: double.infinity,
        height: double.infinity,
        decoration: BoxDecoration(
          gradient: LinearGradient(
            colors: [Colors.grey.shade800, Colors.grey.shade900, Colors.black],
            begin: Alignment.topCenter,
            end: Alignment.bottomCenter,
          ),
        ),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Container(
              width: MediaQuery.of(context).size.width * 0.9,
              height: MediaQuery.of(context).size.height * 0.7,
              decoration: BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.circular(8),
                boxShadow: [
                  BoxShadow(
                    color: Colors.black.withValues(alpha: 0.5),
                    blurRadius: 10,
                    offset: const Offset(0, 5),
                  ),
                ],
              ),
              child: Stack(
                children: [
                  // 漫画内容
                  Center(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Text(_pageEmojis[pageIndex % _pageEmojis.length], style: TextStyle(fontSize: 100)),
                        Text('第${_currentChapter + 1}话', style: TextStyle(fontSize: 24)),
                        Text('第${pageIndex + 1}页', style: TextStyle(fontSize: 18)),
                      ],
                    ),
                  ),
                  // 页码标识
                  Positioned(
                    bottom: 10,
                    right: 10,
                    child: Container(
                      padding: EdgeInsets.symmetric(horizontal: 12, vertical: 6),
                      decoration: BoxDecoration(
                        color: Colors.black.withValues(alpha: 0.6),
                        borderRadius: BorderRadius.circular(12),
                      ),
                      child: Text('${pageIndex + 1}/$_pagesPerChapter', style: TextStyle(color: Colors.white)),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    ),
  );
}

UI组件设计

1. 漫画卡片

漫画卡片

封面容器

标题

作者

进度信息

渐变背景

Emoji封面

进度条

2. 阅读器界面层次

阅读器页面

PageView内容

顶部菜单

底部菜单

InteractiveViewer

漫画图片

返回按钮

标题

模式切换

进度条

章节按钮

3. 手势交互流程

单击屏幕

单击屏幕

双指捏合

松开手指

滑动

完成

点击目录

选择章节

阅读中

显示菜单

缩放模式

翻页

章节列表

功能扩展建议

1. 真实图片加载

使用Image.network或Image.file加载真实漫画图片:

dependencies:
  cached_network_image: ^3.3.0

Widget _buildComicPage(int pageIndex) {
  return InteractiveViewer(
    minScale: 0.5,
    maxScale: 4.0,
    child: CachedNetworkImage(
      imageUrl: getImageUrl(_currentChapter, pageIndex),
      placeholder: (context, url) => Center(
        child: CircularProgressIndicator(),
      ),
      errorWidget: (context, url, error) => Icon(Icons.error),
      fit: BoxFit.contain,
    ),
  );
}

2. 图片预加载

提前加载下一页图片,提升翻页流畅度:

class ImagePreloader {
  final Map<String, ImageProvider> _cache = {};
  
  void preloadImages(BuildContext context, List<String> urls) {
    for (var url in urls) {
      if (!_cache.containsKey(url)) {
        final provider = NetworkImage(url);
        _cache[url] = provider;
        precacheImage(provider, context);
      }
    }
  }
  
  void clearCache() {
    _cache.clear();
  }
}

// 使用

void initState() {
  super.initState();
  _preloadNextPages();
}

void _preloadNextPages() {
  final urls = <String>[];
  for (int i = _currentPage + 1; i < _currentPage + 3; i++) {
    if (i < _pagesPerChapter) {
      urls.add(getImageUrl(_currentChapter, i));
    }
  }
  imagePreloader.preloadImages(context, urls);
}

3. 文件选择器

使用file_picker选择本地漫画文件:

dependencies:
  file_picker: ^8.0.0+1

Future<void> _importComic() async {
  final result = await FilePicker.platform.pickFiles(
    type: FileType.custom,
    allowedExtensions: ['zip', 'cbz', 'cbr'],
    allowMultiple: false,
  );
  
  if (result != null && result.files.single.path != null) {
    final file = File(result.files.single.path!);
    await _extractAndImportComic(file);
  }
}

4. ZIP文件解压

解压CBZ/CBR格式的漫画文件:

dependencies:
  archive: ^3.4.0

Future<List<String>> extractComicArchive(File archiveFile) async {
  final bytes = await archiveFile.readAsBytes();
  final archive = ZipDecoder().decodeBytes(bytes);
  
  final imagePaths = <String>[];
  final tempDir = await getTemporaryDirectory();
  
  for (var file in archive) {
    if (file.isFile && _isImageFile(file.name)) {
      final data = file.content as List<int>;
      final outputFile = File('${tempDir.path}/${file.name}');
      await outputFile.create(recursive: true);
      await outputFile.writeAsBytes(data);
      imagePaths.add(outputFile.path);
    }
  }
  
  imagePaths.sort();
  return imagePaths;
}

bool _isImageFile(String filename) {
  final ext = filename.toLowerCase().split('.').last;
  return ['jpg', 'jpeg', 'png', 'gif', 'webp'].contains(ext);
}

5. 书签功能

添加书签标记重要页面:

class Bookmark {
  final String comicId;
  final int chapter;
  final int page;
  final String note;
  final DateTime createdTime;
}

class BookmarkManager {
  List<Bookmark> bookmarks = [];
  
  void addBookmark(String comicId, int chapter, int page, String note) {
    bookmarks.add(Bookmark(
      comicId: comicId,
      chapter: chapter,
      page: page,
      note: note,
      createdTime: DateTime.now(),
    ));
    _save();
  }
  
  List<Bookmark> getBookmarks(String comicId) {
    return bookmarks.where((b) => b.comicId == comicId).toList();
  }
}

// 在阅读器中添加书签按钮
IconButton(
  icon: Icon(Icons.bookmark_add),
  onPressed: () => _addBookmark(),
)

6. 阅读模式

实现多种阅读模式:

enum ReadingMode {
  vertical,      // 垂直滚动
  horizontal,    // 水平翻页
  continuous,    // 连续滚动
  webtoon,       // 条漫模式
}

class ReadingModeSelector extends StatelessWidget {
  final ReadingMode currentMode;
  final Function(ReadingMode) onModeChanged;
  
  
  Widget build(BuildContext context) {
    return Row(
      children: [
        _buildModeButton(ReadingMode.vertical, Icons.swap_vert),
        _buildModeButton(ReadingMode.horizontal, Icons.swap_horiz),
        _buildModeButton(ReadingMode.continuous, Icons.view_stream),
        _buildModeButton(ReadingMode.webtoon, Icons.view_column),
      ],
    );
  }
}

7. 亮度调节

添加阅读时的亮度控制:

dependencies:
  screen_brightness: ^0.2.2+1

class BrightnessControl extends StatefulWidget {
  
  State<BrightnessControl> createState() => _BrightnessControlState();
}

class _BrightnessControlState extends State<BrightnessControl> {
  double _brightness = 0.5;
  
  
  void initState() {
    super.initState();
    _loadBrightness();
  }
  
  Future<void> _loadBrightness() async {
    final brightness = await ScreenBrightness().current;
    setState(() {
      _brightness = brightness;
    });
  }
  
  Future<void> _setBrightness(double value) async {
    await ScreenBrightness().setScreenBrightness(value);
    setState(() {
      _brightness = value;
    });
  }
  
  
  Widget build(BuildContext context) {
    return Row(
      children: [
        Icon(Icons.brightness_low),
        Expanded(
          child: Slider(
            value: _brightness,
            onChanged: _setBrightness,
          ),
        ),
        Icon(Icons.brightness_high),
      ],
    );
  }
}

8. 在线漫画源

集成在线漫画API:

class ComicApi {
  static const String baseUrl = 'https://api.example.com';
  
  Future<List<ComicInfo>> searchComics(String keyword) async {
    final response = await http.get(
      Uri.parse('$baseUrl/search?q=$keyword'),
    );
    
    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      return (data['results'] as List)
          .map((json) => ComicInfo.fromJson(json))
          .toList();
    }
    
    throw Exception('Failed to search comics');
  }
  
  Future<List<String>> getChapterImages(String comicId, int chapter) async {
    final response = await http.get(
      Uri.parse('$baseUrl/comics/$comicId/chapters/$chapter/images'),
    );
    
    if (response.statusCode == 200) {
      final data = jsonDecode(response.body);
      return List<String>.from(data['images']);
    }
    
    throw Exception('Failed to load chapter images');
  }
}

9. 下载管理

实现漫画下载功能:

dependencies:
  dio: ^5.0.0

class DownloadManager {
  final Dio _dio = Dio();
  final Map<String, double> _progress = {};
  
  Future<void> downloadChapter(
    String comicId,
    int chapter,
    List<String> imageUrls,
  ) async {
    final dir = await getApplicationDocumentsDirectory();
    final chapterDir = Directory('${dir.path}/$comicId/$chapter');
    await chapterDir.create(recursive: true);
    
    for (int i = 0; i < imageUrls.length; i++) {
      final url = imageUrls[i];
      final savePath = '${chapterDir.path}/page_$i.jpg';
      
      await _dio.download(
        url,
        savePath,
        onReceiveProgress: (received, total) {
          final progress = received / total;
          _progress['$comicId-$chapter-$i'] = progress;
          // 通知UI更新
        },
      );
    }
  }
  
  double getProgress(String comicId, int chapter) {
    // 计算章节整体下载进度
    return 0.0;
  }
}

10. 评论和评分

添加社区功能:

class ComicReview {
  final String userId;
  final String userName;
  final double rating;
  final String comment;
  final DateTime createdTime;
  final List<String> images;
}

class ReviewSection extends StatelessWidget {
  final String comicId;
  final List<ComicReview> reviews;
  
  
  Widget build(BuildContext context) {
    return Column(
      children: [
        // 评分统计
        RatingBar(
          initialRating: _averageRating,
          onRatingUpdate: (rating) {},
        ),
        // 评论列表
        ListView.builder(
          shrinkWrap: true,
          physics: NeverScrollableScrollPhysics(),
          itemCount: reviews.length,
          itemBuilder: (context, index) {
            return ReviewCard(review: reviews[index]);
          },
        ),
      ],
    );
  }
}

性能优化建议

1. 图片内存优化

// 限制图片缓存大小
PaintingBinding.instance.imageCache.maximumSize = 100;
PaintingBinding.instance.imageCache.maximumSizeBytes = 50 * 1024 * 1024; // 50MB

// 及时清理缓存
void clearImageCache() {
  PaintingBinding.instance.imageCache.clear();
  PaintingBinding.instance.imageCache.clearLiveImages();
}

2. 懒加载优化

class LazyPageView extends StatefulWidget {
  
  State<LazyPageView> createState() => _LazyPageViewState();
}

class _LazyPageViewState extends State<LazyPageView> {
  final Map<int, Widget> _pageCache = {};
  
  Widget _buildPage(int index) {
    if (!_pageCache.containsKey(index)) {
      _pageCache[index] = ComicPage(index: index);
      
      // 清理远离当前页的缓存
      _pageCache.removeWhere((key, value) => 
        (key - _currentPage).abs() > 3
      );
    }
    
    return _pageCache[index]!;
  }
}

3. 图片压缩

dependencies:
  image: ^4.0.0

Future<File> compressImage(File file) async {
  final bytes = await file.readAsBytes();
  final image = img.decodeImage(bytes);
  
  if (image == null) return file;
  
  // 限制最大尺寸
  final resized = img.copyResize(
    image,
    width: image.width > 1920 ? 1920 : null,
    height: image.height > 1920 ? 1920 : null,
  );
  
  // 压缩质量
  final compressed = img.encodeJpg(resized, quality: 85);
  
  final compressedFile = File('${file.path}.compressed.jpg');
  await compressedFile.writeAsBytes(compressed);
  
  return compressedFile;
}

测试建议

1. 单元测试

void main() {
  group('ComicInfo', () {
    test('progress calculation', () {
      final comic = ComicInfo(
        id: '1',
        title: 'Test',
        author: 'Author',
        cover: '📚',
        totalChapters: 100,
        addedTime: DateTime.now(),
        currentChapter: 50,
      );
      
      comic.progress = comic.currentChapter / comic.totalChapters;
      expect(comic.progress, 0.5);
    });
  });
}

2. Widget测试

void main() {
  testWidgets('Comic card displays correctly', (tester) async {
    final comic = ComicInfo(
      id: '1',
      title: 'Test Comic',
      author: 'Test Author',
      cover: '📚',
      totalChapters: 100,
      addedTime: DateTime.now(),
    );
    
    await tester.pumpWidget(
      MaterialApp(
        home: Scaffold(
          body: ComicCard(comic: comic),
        ),
      ),
    );
    
    expect(find.text('Test Comic'), findsOneWidget);
    expect(find.text('Test Author'), findsOneWidget);
  });
}

3. 手势测试

void main() {
  testWidgets('Pinch to zoom', (tester) async {
    await tester.pumpWidget(MyApp());
    
    // 打开漫画
    await tester.tap(find.byType(ComicCard).first);
    await tester.pumpAndSettle();
    
    // 模拟缩放手势
    final center = tester.getCenter(find.byType(InteractiveViewer));
    await tester.startGesture(center);
    // 测试缩放逻辑
  });
}

部署发布

Android配置

<!-- android/app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"/>

iOS配置

<!-- ios/Runner/Info.plist -->
<key>NSPhotoLibraryUsageDescription</key>
<string>需要访问相册以导入漫画</string>

构建命令

# Android
flutter build apk --release

# iOS
flutter build ios --release

项目总结

本漫画阅读器应用展示了以下Flutter开发技能:

  1. InteractiveViewer:实现图片缩放和平移
  2. PageView:流畅的翻页效果
  3. 手势交互:点击、滑动、缩放等手势
  4. 网格布局:GridView展示漫画封面
  5. 状态管理:多页面状态同步
  6. 数据持久化:SharedPreferences保存进度
  7. 模态对话框:章节选择和设置
  8. 自定义UI:渐变背景、阴影效果

通过这个项目,你可以学习到Flutter应用开发的高级交互技巧,特别是图片处理和手势控制。在此基础上,可以继续扩展更多功能,打造功能完善的漫画阅读器应用。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐