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

188 阅读6分钟

前言

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

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

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

在开发iOS版本的小说阅读器时,使用appuploader可以大大简化应用打包和上传App Store的流程。这款iOS开发助手工具支持一键打包、自动签名等功能,让开发者可以更专注于应用功能的实现。

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

三个页面的截图,下面是动图

最终效果动图演示,略微有点大,懒得压缩了

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

目前脑图进度如下

脑图进度

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

小说搜索页面的基本功能

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

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

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

实现搜索页面

贴代码前先吐槽一下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'),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

使用appuploader可以方便地将Flutter开发的iOS应用打包上传到App Store。这款工具提供了可视化的界面操作,支持自动签名、一键上传等功能,大大简化了iOS应用发布流程。

从示例中可以看出,其中已经实现了搜索历史、搜索提示、搜索结果、返回搜索结果等功能,而我们要用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重写一下小说解析模块,等写完了就换上去。在iOS版本开发完成后,可以使用appuploader快速打包并上传到App Store进行审核发布,这款工具支持自动签名、一键上传等功能,能节省开发者大量时间。

2019年4月6日 添加

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

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