基于Flutter实现的小说阅读器——BITReader ,相信我你也可以变成光!

2,792 阅读4分钟
6d95f5df68248bb55b5b97b4502332711ff7d073.png@2560w_400h_100q_1o.webp

前言

最近感觉自己有点颓废,左思右想后觉得不能这样浪费时间,天天来摆烂。受到了群友的激励以及最近自己喜欢看小说。就想我能不能自己也做一款小说阅读器出来呢。在最开始的时候花了一段时间写了一个版本。当时用的是一个开源的接口,当我写好后使用了两天接口挂了我就只有大眼瞪小眼了。之后在 FlutterCandies里面咨询了群友,发现了一种使用外部提供书籍数据源的方法可以避免数据来源挂掉,说干就干vscode启动!


项目地址

github.com/fluttercand…

项目介绍

BITReader是一款基于Flutter实现的小说阅读器

当前功能包含:

  • 源搜索:使用内置数据来源进行搜索数据(后续更新:用户可以自行导入来源进行源搜索
  • 收藏书架
  • 阅读历史记录
  • 阅读设置:字号设置,字体颜色更改,自定义阅读背景(支持调色板自定义选择,支持image设置为背景
  • 主题设置:支持九种颜色的主题样式
  • 书籍详情:展示书籍信息以及章节目录等书籍信息

支持平台

平台是否支持
Android
IOS
Windows
MacOS
Web
Linux

项目截图

729_1x_shots_so.png 360_1x_shots_so.png 57_1x_shots_so.png 300_1x_shots_so.png 402_1x_shots_so.png

mac运行截图

CE7D99422AA2804700F33FC94D273EC7.png

windows运行截图

d7a40aa1-1572-4969-9d78-55d2abcd791b.png

项目结构

lib
├── main.dart -- 入口
├── assets -- 本地资源生成
├── base -- 请求状态、页面状态
├── db -- 数据缓存
├── icons -- 图标
├── net -- 网络请求、网络状态
├── n_pages
    ├── detail -- 详情页
    ├── home -- 首页
    ├── search -- 全网搜索搜索页
    ├── history -- 历史记录 
    ├── read -- 小说阅读 
    └── like -- 收藏书架
├── pages  已废弃⚠
    ├── home -- 首页
    ├── novel -- 小说阅读
    ├── search -- 全网搜索
    ├── category -- 小说分类
    ├── detail_novel -- 小说详情
    ├── book_novel -- 书架、站源
    └── collect_novel -- 小说收藏
├── route -- 路由
└── theme -- 主题管理
    └── themes -- 主题颜色-9种颜色
├── tools -- 工具类 、解析工具、日志、防抖。。。
└── widget -- 自定义组件、工具 、加载、状态、图片 等。。。。。。

阅读器主要包含的模块

  • 阅读显示:文本解析,对文本进行展示处理
  • 数据解析: 数据源的解析,以及数据来源的解析(目前只支持简单数据源格式解析、后续可能会更新更多格式解析
  • 功能:阅读翻页样式、字号、背景、背景图、切换章节、收藏、历史记录、本地缓存等

阅读显示

阅读文本展示我用的是extended_text因为支持自定义效果很好。

实现的效果把文本中 “ ” 引用起来的文本自定义成我自己想要的效果样式。

class MateText extends SpecialText {
  MateText(
    TextStyle? textStyle,
    SpecialTextGestureTapCallback? onTap, {
    this.showAtBackground = false,
    required this.start,
    required this.color,
  }) : super(flag, '”', textStyle, onTap: onTap);
  static const String flag = '“';
  final int start;
  final Color color;

  /// whether show background for @somebody
  final bool showAtBackground;

  @override
  InlineSpan finishText() {
    final TextStyle textStyle =
        this.textStyle?.copyWith(color: color) ?? const TextStyle();

    final String atText = toString();

    return showAtBackground
        ? BackgroundTextSpan(
            background: Paint()..color = Colors.blue.withOpacity(0.15),
            text: atText,
            actualText: atText,
            start: start,

            ///caret can move into special text
            deleteAll: true,
            style: textStyle,
            recognizer: (TapGestureRecognizer()
              ..onTap = () {
                if (onTap != null) {
                  onTap!(atText);
                }
              }))
        : SpecialTextSpan(
            text: atText,
            actualText: atText,
            start: start,
            style: textStyle,
            recognizer: (TapGestureRecognizer()
              ..onTap = () {
                if (onTap != null) {
                  onTap!(atText);
                }
              }));
  }
}

class NovelSpecialTextSpanBuilder extends SpecialTextSpanBuilder {
  NovelSpecialTextSpanBuilder({required this.color});
  Color color;
  set setColor(Color c) => color = c;
  @override
  SpecialText? createSpecialText(String flag,
      {TextStyle? textStyle,
      SpecialTextGestureTapCallback? onTap,
      int? index}) {
    if (flag == '') {
      return null;
    } else if (isStart(flag, AtText.flag)) {
      return AtText(
        textStyle,
        onTap,
        start: index! - (AtText.flag.length - 1),
        color: color,
      );
    } else if (isStart(flag, MateText.flag)) {
      return MateText(
        textStyle,
        onTap,
        start: index! - (MateText.flag.length - 1),
        color: color,
      );
    }
    // index is end index of start flag, so text start index should be index-(flag.length-1)
    return null;
  }
}

数据解析编码格式转换

首先数据是有不同的编码格式,否则我们直接展示可能会导致乱码问题。 先把数据给根据查找到的编码类型来做单独的处理转换。

/// 解析html数据 解码 不同编码
  static String parseHtmlDecode(dynamic htmlData) {
    String resultData = gbk.decode(htmlData);
    final charset = ParseSourceRule.parseCharset(htmlData: resultData) ?? "gbk";
    if (charset.toLowerCase() == "utf-8" || charset.toLowerCase() == "utf8") {
      resultData = utf8.decode(htmlData);
    }
    return resultData;
  }
 static String? parseCharset({
    required String htmlData,
  }) {   
    Document document = parse(htmlData);
  
    List<Element> metaTags = document.getElementsByTagName('meta').toList();
    for (Element meta in metaTags) {
      String? charset = meta.attributes['charset'];
      String content = meta.attributes['content'] ??
          ""; //<meta http-equiv="Content-Type" content="text/html; charset=utf-8">

      if (charset != null) {
        return charset; 
      }
      List<String> parts = content.split(';');
      for (String part in parts) {
        part = part.trim();
        if (part.startsWith('charset=')) {
          return part.split('=').last.trim();
        }
      }
    }

    return null; 
  }

数据结构解析-代码太多只展示部分

Document document = parse(htmlData);

    //
    List<Element> rootNodes = [];
    if (rootSelector != null && rootSelector.isNotEmpty) {
      // 
      List<String> rootParts = rootSelector.split(RegExp(r'[@>]'));
      String initialPart = rootParts[0].trim();

      //
      if (initialPart.startsWith('class.')) {
        String className = initialPart.split('.')[1];
        rootNodes = document.getElementsByClassName(className).toList();
      } else if (initialPart.startsWith('.')) {
        String className = initialPart.substring(1);
        rootNodes = document.getElementsByClassName(className).toList();
      } else if (initialPart.startsWith('#')) {
        String idSelector = initialPart.substring(1);
        rootNodes = document.querySelectorAll('#$idSelector').toList();
      } else if (initialPart.startsWith('id.')) {
        String idSelector = initialPart.split('.')[1];
        var element = document.querySelector('#$idSelector');
        if (element != null) {
          rootNodes.add(element);
        }
      } else if (initialPart.contains(' ')) {
        String idSelector = initialPart.replaceAll(' ', ">");
        var element = document.querySelector(idSelector);
        if (element != null) {
          rootNodes.add(element);
        }
      } else {
        rootNodes = document.getElementsByTagName(initialPart).toList();
      }

存储工具类 - 部分代码

/// shared_preferences
class PreferencesDB {
  PreferencesDB._();
  static final PreferencesDB instance = PreferencesDB._();
  SharedPreferencesAsync? _instance;
  SharedPreferencesAsync get sps => _instance ??= SharedPreferencesAsync();

  /*** APP相关 ***/

  /// 主题外观模式
  ///
  /// system(默认):跟随系统 light:普通 dark:深色
  static const appThemeDarkMode = 'appThemeDarkMode';

  /// 多主题模式
  ///
  /// default(默认)
  static const appMultipleThemesMode = 'appMultipleThemesMode';

  /// 字体大小
  ///
  ///
  static const fontSize = 'fontSize';

  /// 字体粗细
  static const fontWeight = 'fontWeight';

  /// 设置-主题外观模式
  Future<void> setAppThemeDarkMode(ThemeMode themeMode) async {
    await sps.setString(appThemeDarkMode, themeMode.name);
  }

  /// 获取-主题外观模式
  Future<ThemeMode> getAppThemeDarkMode() async {
    final String themeDarkMode =
        await sps.getString(appThemeDarkMode) ?? 'system';
    return darkThemeMode(themeDarkMode);
  }

  /// 设置-多主题模式
  Future<void> setMultipleThemesMode(String value) async {
    await sps.setString(appMultipleThemesMode, value);
  }

  /// 获取-多主题模式
  Future<String> getMultipleThemesMode() async {
    return await sps.getString(appMultipleThemesMode) ?? 'default';
  }

  /// 获取-fontsize 大小 默认18
  Future<double> getNovelFontSize() async {
    return await sps.getDouble(fontSize) ?? 18;
  }

  /// 设置 -fontsize 大小
  Future<void> setNovelFontSize(double size) async {
    await sps.setDouble(fontSize, size);
  }

  /// 设置-多主题模式
  Future<void> setNovelFontWeight(NovelReadFontWeightEnum value) async {
    await sps.setString(fontWeight, value.id);
  }

  /// 获取-多主题模式
  Future<String> getNovelFontWeight() async {
    return await sps.getString(fontWeight) ?? 'w300';
  }
}

最后

特别鸣谢FlutterCandies糖果社区,也欢迎加入我们的大家庭。让我们一起学习共同进步

免责声明:本项目提供的源代码仅用学习,请勿用于商业盈利。