Flutter小说阅读器系列二:使用Bloc模式实现小说搜索的基本功能(略微有点长)
前言
以前的小说阅读器一般都是针对特定的网站写好解析规则,或者干脆就是自己服务器先采集好然后提供数据接口给阅读器调用。
而如今为了规避风险,我们可以直接使用搜索引擎进行搜索并编写涵盖面广的通用解析规则进行解析。只要解析规则写得好,那么任何网站都能解析出来,毕竟大部分小说盗版网站的页面结构都差不多。
说白了,现在做个小说阅读器其实就是在做一个盗版小说网站转码器,自己不提供任何数据,只根据用户提供的关键字去搜索引擎中搜索,然后根据用户选择的搜索结果进行解析转码,只提取纯粹的章节列表和章节内容。
提示:Gif图片略大,请注意流量!
没看过第一篇的请先看上面的文章,因为这篇文章是接着上一篇来的。
上篇文章中已经通过Bloc模式实现了小说搜索时的关键字提示,这篇文章就主要说下小说搜索页面基本功能的实现,先上个最终效果动图。
上图中书架等功能都还只是实现了基本界面,功能全都还没写,目前主要是先实现小说搜索功能以及解析章节以及章节列表的功能。
目前脑图进度如下
写第一篇文章的时候我发现界面太丑实在有些受不了,所以就先花了点时间把全局主题模块实现了,这样看起来就舒服很多了。
从脑图中可以看出,书籍搜索分支下的结果解析还没实现,这主要是因为我目前用的是我以前写的golang版本,因为flutter可以直接调用golang写的包,不过考虑到调用时性能开销问题,所以打算以后再用dart将小说章节及章节列表的解析模块重写。
小说搜索页面的基本功能
一个完整的搜索页面可以包含不少东西,不过目前我只做了最基本的搜索建议与获取搜索结果的功能。以后也许会将以下功能加入进去。
- 搜索历史记录,这个很简单,后续写到本地存储时会顺便加上。
- 搜索书籍推荐,这个目前默认使用的是起点的搜索推荐,也就是上面演示中当query为空时显示的列表,后期会换成从自己服务端获取推荐列表等数据。
- 语音搜索,之前本来想加上的,不过考虑到现在输入法基本上都有语音输入,所以放弃了。
- 切换搜索建议源切换搜索引擎源,这个等自己实现的聚合搜索模块完成就可以放上去。
目前演示中我使用的是起点搜索提示接口+解析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)