首发于 Flutter实践
Flutter小说阅读器系列二:使用Bloc模式实现小说搜索的基本功能(略微有点长)

Flutter小说阅读器系列二:使用Bloc模式实现小说搜索的基本功能(略微有点长)

前言

以前的小说阅读器一般都是针对特定的网站写好解析规则,或者干脆就是自己服务器先采集好然后提供数据接口给阅读器调用。

而如今为了规避风险,我们可以直接使用搜索引擎进行搜索并编写涵盖面广的通用解析规则进行解析。只要解析规则写得好,那么任何网站都能解析出来,毕竟大部分小说盗版网站的页面结构都差不多。

说白了,现在做个小说阅读器其实就是在做一个盗版小说网站转码器,自己不提供任何数据,只根据用户提供的关键字去搜索引擎中搜索,然后根据用户选择的搜索结果进行解析转码,只提取纯粹的章节列表和章节内容。

提示:Gif图片略大,请注意流量!

没看过第一篇的请先看上面的文章,因为这篇文章是接着上一篇来的。

上篇文章中已经通过Bloc模式实现了小说搜索时的关键字提示,这篇文章就主要说下小说搜索页面基本功能的实现,先上个最终效果动图。

三个页面的截图,下面是动图
最终效果动图演示,略微有点大,懒得压缩了

上图中书架等功能都还只是实现了基本界面,功能全都还没写,目前主要是先实现小说搜索功能以及解析章节以及章节列表的功能。

目前脑图进度如下

脑图进度

写第一篇文章的时候我发现界面太丑实在有些受不了,所以就先花了点时间把全局主题模块实现了,这样看起来就舒服很多了。

从脑图中可以看出,书籍搜索分支下的结果解析还没实现,这主要是因为我目前用的是我以前写的golang版本,因为flutter可以直接调用golang写的包,不过考虑到调用时性能开销问题,所以打算以后再用dart将小说章节及章节列表的解析模块重写。

小说搜索页面的基本功能

一个完整的搜索页面可以包含不少东西,不过目前我只做了最基本的搜索建议与获取搜索结果的功能。以后也许会将以下功能加入进去。

  1. 搜索历史记录,这个很简单,后续写到本地存储时会顺便加上。
  2. 搜索书籍推荐,这个目前默认使用的是起点的搜索推荐,也就是上面演示中当query为空时显示的列表,后期会换成从自己服务端获取推荐列表等数据。
  3. 语音搜索,之前本来想加上的,不过考虑到现在输入法基本上都有语音输入,所以放弃了。
  4. 切换搜索建议源切换搜索引擎源,这个等自己实现的聚合搜索模块完成就可以放上去。

目前演示中我使用的是起点搜索提示接口+解析360搜索引擎的搜索结果。

原本我想过flutter只负责界面展示,其他数据全都从自己用golang写的接口中获取,但后来选择了放弃。除了法律风险之外还会给我的服务器带宽带来不小的压力,毕竟自己服务器主要用途就是用来爬墙找爱情动作片进行学习的。

实现搜索页面

贴代码前先吐槽一下flutter团队,很多文档不全组件都得自己尝试也就算了,有的干脆就没有,最操蛋的就是一些最基本的bug都没解决,比如非阿拉伯字符光标问题,几个月了android端的还没解决(ios端的解决了)。

如果你没翻过官方的 flutter_gallery项目,那么你肯定不知道flutter中还提供了 showSearch 这么个玩意,这是flutter默认提供的一个搜索面板,具体效果就是上面效果图中的样子。

下面是使用showSearch搜索页面的示例代码

import 'package:flutter/material.dart';

import '../../gallery/demo.dart';

class SearchDemo extends StatefulWidget {
  static const String routeName = '/material/search';

  @override
  _SearchDemoState createState() => _SearchDemoState();
}

class _SearchDemoState extends State<SearchDemo> {
  final _SearchDemoSearchDelegate _delegate = _SearchDemoSearchDelegate();
  final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();

  int _lastIntegerSelected;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      key: _scaffoldKey,
      appBar: AppBar(
        leading: IconButton(
          tooltip: 'Navigation menu',
          icon: AnimatedIcon(
            icon: AnimatedIcons.menu_arrow,
            color: Colors.white,
            progress: _delegate.transitionAnimation,
          ),
          onPressed: () {
            _scaffoldKey.currentState.openDrawer();
          },
        ),
        title: const Text('Numbers'),
        actions: <Widget>[
          IconButton(
            tooltip: 'Search',
            icon: const Icon(Icons.search),
            onPressed: () async {
              final int selected = await showSearch<int>(
                context: context,
                delegate: _delegate,
              );
              if (selected != null && selected != _lastIntegerSelected) {
                setState(() {
                  _lastIntegerSelected = selected;
                });
              }
            },
          ),
          MaterialDemoDocumentationButton(SearchDemo.routeName),
          IconButton(
            tooltip: 'More (not implemented)',
            icon: Icon(
              Theme.of(context).platform == TargetPlatform.iOS
                  ? Icons.more_horiz
                  : Icons.more_vert,
            ),
            onPressed: () { },
          ),
        ],
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            MergeSemantics(
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  Row(
                    mainAxisAlignment: MainAxisAlignment.center,
                    children: const <Widget>[
                      Text('Press the '),
                      Tooltip(
                        message: 'search',
                        child: Icon(
                          Icons.search,
                          size: 18.0,
                        ),
                      ),
                      Text(' icon in the AppBar'),
                    ],
                  ),
                  const Text('and search for an integer between 0 and 100,000.'),
                ],
              ),
            ),
            const SizedBox(height: 64.0),
            Text('Last selected integer: ${_lastIntegerSelected ?? 'NONE' }.'),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton.extended(
        tooltip: 'Back', // Tests depend on this label to exit the demo.
        onPressed: () {
          Navigator.of(context).pop();
        },
        label: const Text('Close demo'),
        icon: const Icon(Icons.close),
      ),
      drawer: Drawer(
        child: Column(
          children: <Widget>[
            const UserAccountsDrawerHeader(
              accountName: Text('Peter Widget'),
              accountEmail: Text('peter.widget@example.com'),
              currentAccountPicture: CircleAvatar(
                backgroundImage: AssetImage(
                  'people/square/peter.png',
                  package: 'flutter_gallery_assets',
                ),
              ),
              margin: EdgeInsets.zero,
            ),
            MediaQuery.removePadding(
              context: context,
              // DrawerHeader consumes top MediaQuery padding.
              removeTop: true,
              child: const ListTile(
                leading: Icon(Icons.payment),
                title: Text('Placeholder'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

class _SearchDemoSearchDelegate extends SearchDelegate<int> {
  final List<int> _data = List<int>.generate(100001, (int i) => i).reversed.toList();
  final List<int> _history = <int>[42607, 85604, 66374, 44, 174];

  @override
  Widget buildLeading(BuildContext context) {
    return IconButton(
      tooltip: 'Back',
      icon: AnimatedIcon(
        icon: AnimatedIcons.menu_arrow,
        progress: transitionAnimation,
      ),
      onPressed: () {
        close(context, null);
      },
    );
  }

  @override
  Widget buildSuggestions(BuildContext context) {

    final Iterable<int> suggestions = query.isEmpty
        ? _history
        : _data.where((int i) => '$i'.startsWith(query));

    return _SuggestionList(
      query: query,
      suggestions: suggestions.map<String>((int i) => '$i').toList(),
      onSelected: (String suggestion) {
        query = suggestion;
        showResults(context);
      },
    );
  }

  @override
  Widget buildResults(BuildContext context) {
    final int searched = int.tryParse(query);
    if (searched == null || !_data.contains(searched)) {
      return Center(
        child: Text(
          '"$query"\n is not a valid integer between 0 and 100,000.\nTry again.',
          textAlign: TextAlign.center,
        ),
      );
    }

    return ListView(
      children: <Widget>[
        _ResultCard(
          title: 'This integer',
          integer: searched,
          searchDelegate: this,
        ),
        _ResultCard(
          title: 'Next integer',
          integer: searched + 1,
          searchDelegate: this,
        ),
        _ResultCard(
          title: 'Previous integer',
          integer: searched - 1,
          searchDelegate: this,
        ),
      ],
    );
  }

  @override
  List<Widget> buildActions(BuildContext context) {
    return <Widget>[
      query.isEmpty
          ? IconButton(
              tooltip: 'Voice Search',
              icon: const Icon(Icons.mic),
              onPressed: () {
                query = 'TODO: implement voice input';
              },
            )
          : IconButton(
              tooltip: 'Clear',
              icon: const Icon(Icons.clear),
              onPressed: () {
                query = '';
                showSuggestions(context);
              },
            ),
    ];
  }
}

class _ResultCard extends StatelessWidget {
  const _ResultCard({this.integer, this.title, this.searchDelegate});

  final int integer;
  final String title;
  final SearchDelegate<int> searchDelegate;

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    return GestureDetector(
      onTap: () {
        searchDelegate.close(context, integer);
      },
      child: Card(
        child: Padding(
          padding: const EdgeInsets.all(8.0),
          child: Column(
            children: <Widget>[
              Text(title),
              Text(
                '$integer',
                style: theme.textTheme.headline.copyWith(fontSize: 72.0),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class _SuggestionList extends StatelessWidget {
  const _SuggestionList({this.suggestions, this.query, this.onSelected});

  final List<String> suggestions;
  final String query;
  final ValueChanged<String> onSelected;

  @override
  Widget build(BuildContext context) {
    final ThemeData theme = Theme.of(context);
    return ListView.builder(
      itemCount: suggestions.length,
      itemBuilder: (BuildContext context, int i) {
        final String suggestion = suggestions[i];
        return ListTile(
          leading: query.isEmpty ? const Icon(Icons.history) : const Icon(null),
          title: RichText(
            text: TextSpan(
              text: suggestion.substring(0, query.length),
              style: theme.textTheme.subhead.copyWith(fontWeight: FontWeight.bold),
              children: <TextSpan>[
                TextSpan(
                  text: suggestion.substring(query.length),
                  style: theme.textTheme.subhead,
                ),
              ],
            ),
          ),
          onTap: () {
            onSelected(suggestion);
          },
        );
      },
    );
  }
}

从示例中可以看出,其中已经实现了搜索历史、搜索提示、搜索结果、返回搜索结果等功能,而我们要用showSearch就得实现自己的SearchDelegate。

调用showSearch很简单,但实现SearchDelegate之前我们得先知道需要实现哪些东西。

我看了下SearchDelegate的实现,要实现的widget如下:

  • buildLeading:用于处理搜索栏左侧的返回箭头,可以返回null或者返回自定义数据
  • buildActions:用于处理搜索栏右侧的功能按钮,可以在上面放各种小部件
  • buildSuggestions:用于处理关键字提示,每当query有变化时都会自动触发。需要注意的是,当加载进入页面时会默认触发一次,输入法弹出与隐藏时也会切换一次。
  • buildResults:用于处理搜索结果,每当按下键盘上的搜索键或者showResults被调用时会触发。
  • appBarTheme:官方示例中并没有写出,还是我翻代码看到的,如果不重写的话就会默认使用白色的搜索面板,而不会跟随自己定义的主题进行变化。

上面一共五个widget,真正需要注意的就只要buildSuggestions与buildResults,其他的基本可以无事

根据上面的示例写出自己的SearchDelegate如下:

import 'package:flutter/material.dart';
/// 这里导入待会实现的两个widget


/// 注意 SearchDelegate<int> 制定了int,那么返回也必须是int类型。
/// 这个可以自己改,可以改成返回一个结果模型,不过目前还没用到所以我这里就没有该
class SearchNovelDelegate extends SearchDelegate<int> {
  // 返回箭头
  @override
  Widget buildLeading(BuildContext context) {
    return IconButton(
      icon: AnimatedIcon(
        icon: AnimatedIcons.menu_arrow,
        progress: transitionAnimation,
      ),
      onPressed: () {
        close(context, null);
      },
    );
  }

  // 关键字提示
  @override
  Widget buildSuggestions(BuildContext context) {
    return Suggestions(
      delegate: this,
      query: query,
    );
  }

  // 显示结果
  @override
  Widget buildResults(BuildContext context) {
    return SearchResult(
      delegate: this,
      query: query,
    );
  }

  // 重写主题
  @override
  ThemeData appBarTheme(BuildContext context) {
    return Theme.of(context).copyWith(
      // 这里可以单独定制你的搜索页面样式,当然你也可以无视,只要重写了这个widget就行
    );
  }

  // 搜索框右侧图标
  @override
  List<Widget> buildActions(BuildContext context) {
    return <Widget>[
      query.isEmpty
          ? IconButton(
              tooltip: '语音输入',
              icon: const Icon(Icons.mic),
              onPressed: () {
                // query = '';
              },
            )
          : IconButton(
              tooltip: '清除',
              icon: const Icon(Icons.clear),
              onPressed: () {
                query = '';
                showSuggestions(context);
              },
            ),
    ];
  }
}

上面已经实现了自己的SearchDelegate,那么接下来就得实现buildResults的SearchResult与buildSuggestions的Suggestions了。

buildSuggestions的Suggestions我们可以直接使用上篇文章实现的Bloc,这样只要实现个widget进行调用就行。

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// 请在此处导入上面文章中实现的Suggestions bloc

class Suggestions extends StatefulWidget {
  final SearchDelegate delegate;
  final String query;
  Suggestions({this.delegate, this.query});
  @override
  _SuggestionsState createState() => _SuggestionsState();
}

class _SuggestionsState extends State<Suggestions> {
  final SuggestionBloc _suggestion = SuggestionBloc();
  @override
  void dispose() {
    _suggestion.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    _suggestion.dispatch(SuggestionFetch(query: widget.query));
    return BlocBuilder(
      bloc: _suggestion,
      builder: (BuildContext context, SuggestionState state) {
        if (state is SuggestionUninitialized || state is SuggestionLoading) {
          return Center(
            child: CircularProgressIndicator(),
          );
        } else if (state is SuggestionError) {
          return Center(
            child: Text('获取失败'),
          );
        } else if (state is SuggestionLoaded) {
          return ListView.builder(
            itemBuilder: (BuildContext context, int index) {
              return ListTile(
                leading: Icon(Icons.done_all),
                title: Text(state.res[index]),
                onTap: () {
                  widget.delegate.query = state.res[index];
                  widget.delegate.showResults(context);
                },
              );
            },
            itemCount: state.res.length,
          );
        }
      },
    );
  }
}

接下来就是实现buildResults的SearchResult了,依然还是先实现获取搜索结果的接口。

在这里我使用的是360搜索引擎,通过html包对360搜索引擎返回的结果进行解析。

先实现一个类用于处理数据

class SearchResult {
  String title;
  String source;

  SearchResult({this.title, this.source});
}

再实现接口

///放入上篇文章中的的api类里

  // 搜索
  Future<List<SearchResult>> search(name) async {
    http.Response response = await http.get("https://www.so.com/s?ie=utf-8&q=$name");
    var document = parse(response.body);
    var app = document.querySelectorAll('.res-title a');
    List<SearchResult> res = [];
    app.forEach((f) {
      res.add(
        SearchResult(
          title: f.text,
          source: f.attributes["data-url"] ?? f.attributes["href"],
        ),
      );
    });
    return res;
  }

接口实现了就简单了,依照之前实现suggestion_bloc的方式,将search的bloc也实现一遍,两者基本没什么差别。

先实现负责状态的SearchState

/// 导入上面实现的SearchResult类

abstract class SearchState {}

class SearchError extends SearchState {
  @override
  String toString() => 'SearchError:获取失败';
}

class SearchUninitialized extends SearchState {
  @override
  String toString() => 'SearchUninitialized:未初始化';
}

class SearchLoading extends SearchState {
  @override
  String toString() => 'SearchLoading :正在加载';
}

class SearchLoaded extends SearchState {
  final List<SearchResult> res;

  SearchLoaded({
    this.res,
  });

  @override
  String toString() => 'SearchLoaded:加载完毕';
}

然后就是SearchEvent,依然先只实现一个事件

abstract class SearchEvent {}

class SearchFetch extends SearchEvent {
  final String query;

  SearchFetch({this.query});

  @override
  String toString() => 'SearchFetch:获取搜索结果事件';
}

最后是SearchBloc,

import 'dart:async';
import 'package:bloc/bloc.dart';
/// 这里导入api类与上面的SearchEvent与SearchState文件

class SearchBloc extends Bloc<SearchEvent, SearchState> {
  @override
  SearchState get initialState => SearchUninitialized();

  @override
  Stream<SearchState> mapEventToState(
    SearchState currentState,
    SearchEvent event,
  ) async* {
    if (event is SearchFetch) {
      try {
        yield SearchLoading();
        final res = await api.search(event.query);
        yield SearchLoaded(res: res);
      } catch (_) {
        yield SearchError();
      }
    }
  }
}

search的bloc实现好了之后就剩下实现SearchResult这个widget了。

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:suiyi/blocs/search/bloc.dart';

class SearchResult extends StatefulWidget {
  final SearchDelegate delegate;
  final String query;
  SearchResult({this.delegate, this.query});
  @override
  _SearchResultState createState() => _SearchResultState();
}

class _SearchResultState extends State<SearchResult> {
  final SearchBloc _search = SearchBloc();
  String old;
  @override
  void dispose() {
    _search.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    if (old != widget.query) {
      _search.dispatch(SearchFetch(query: widget.query));
      old = widget.query;
    }
    return BlocBuilder(
      bloc: _search,
      builder: (BuildContext context, SearchState state) {
        if (state is SearchUninitialized || state is SearchLoading) {
          return Center(
            child: CircularProgressIndicator(),
          );
        } else if (state is SearchError) {
          return Center(
            child: Text('获取失败'),
          );
        } else if (state is SearchLoaded) {
          return ListView.builder(
            itemBuilder: (BuildContext context, int index) {
              return ListTile(
                dense: true,
                leading: Icon(
                  Icons.bookmark_border,
                  size: 32,
                ),
                title: Text(
                  state.res[index].title,
                  overflow: TextOverflow.ellipsis,
                ),
                subtitle: Text(state.res[index].source),
                onTap: () {
                  // 在这里对选中的结果进行解析,因为我目前是用golang实现的,所以就没贴代码了。
                  print(state.res[index].source);
                },
              );
            },
            itemCount: state.res.length,
          );
        }
      },
    );
  }
}

到此,这个小说搜索页面的功能基本上算是完成了。

因为目前我的小说解析模块是之前用golang写的,现在是以插件的形式调用,所以代码里没放上去,你可以自己用html包或者正则实现一个通用解析模块。

目前正打算用dart重写一下小说解析模块,等写完了就换上去。


2019年4月6日 添加

我写文章的时候bloc版本还是0.10.0,这个版本的mapEventToState是有两个参数的,目前从bloc0.11.0开始只有一个参数了,因此只需要传入事件就行。

Stream<S> mapEventToState(S currentState, E event) -> Stream<S> mapEventToState(E event)

聚圣源店起名测分女孩起啥名字好成神2019世界男篮排名起名大全工具王氏起名2019猪中国银行跨行转账热血传奇补丁思念的诗句属马人起名字学而思官网网络版代驾公司起什么名字合适王起个名字杂交水稻的意义柴犬起名傅立起名字绝世无双金姓的女孩起名秋收起义名词解释新生婴儿起名公司医疗器材公司起名欣赏野原琳布衣总统孙中山火字旁的字男孩起名成语寓意好的起名称黑嘴起重链条厂家前20名排名最强蜗牛密令姓名郑起名大全男生淀粉肠小王子日销售额涨超10倍罗斯否认插足凯特王妃婚姻让美丽中国“从细节出发”清明节放假3天调休1天男孩疑遭霸凌 家长讨说法被踢出群国产伟哥去年销售近13亿网友建议重庆地铁不准乘客携带菜筐雅江山火三名扑火人员牺牲系谣言代拍被何赛飞拿着魔杖追着打月嫂回应掌掴婴儿是在赶虫子山西高速一大巴发生事故 已致13死高中生被打伤下体休学 邯郸通报李梦为奥运任务婉拒WNBA邀请19岁小伙救下5人后溺亡 多方发声王树国3次鞠躬告别西交大师生单亲妈妈陷入热恋 14岁儿子报警315晚会后胖东来又人满为患了倪萍分享减重40斤方法王楚钦登顶三项第一今日春分两大学生合买彩票中奖一人不认账张家界的山上“长”满了韩国人?周杰伦一审败诉网易房客欠租失踪 房东直发愁男子持台球杆殴打2名女店员被抓男子被猫抓伤后确诊“猫抓病”“重生之我在北大当嫡校长”槽头肉企业被曝光前生意红火男孩8年未见母亲被告知被遗忘恒大被罚41.75亿到底怎么缴网友洛杉矶偶遇贾玲杨倩无缘巴黎奥运张立群任西安交通大学校长黑马情侣提车了西双版纳热带植物园回应蜉蝣大爆发妈妈回应孩子在校撞护栏坠楼考生莫言也上北大硕士复试名单了韩国首次吊销离岗医生执照奥巴马现身唐宁街 黑色着装引猜测沈阳一轿车冲入人行道致3死2伤阿根廷将发行1万与2万面值的纸币外国人感慨凌晨的中国很安全男子被流浪猫绊倒 投喂者赔24万手机成瘾是影响睡眠质量重要因素春分“立蛋”成功率更高?胖东来员工每周单休无小长假“开封王婆”爆火:促成四五十对专家建议不必谈骨泥色变浙江一高校内汽车冲撞行人 多人受伤许家印被限制高消费

聚圣源 XML地图 TXT地图 虚拟主机 SEO 网站制作 网站优化