最近难得有些闲暇时间,所以我又打算做一个小说阅读器,以前倒是用RN+Golang写了一个,不过当时太过放飞自我导致自己看起来都很费力,这次我准备换成Flutter试一下。
先简单将小说阅读器分为以下几个部分:
- 书架
- 书库
- 搜索
- 阅读
- 缓存
其中书架和书库最容易却也最繁琐,所以将这两个放到后面,第一步的话先实现搜索中的关键字提示功能。
对于小说关键字提示的数据来源,最开始的时候我选择的是百度,但通过对比后发现起点的关键字提示似乎更合适,通过上面的两张图片可以明显的看出来。
一:获取起点小说关键字接口并实现对应的Suggestion类
确定选择起点,那就只需要把起点的关键字提示接口扒出来,起点关键字提示接口如下:
链接:https://www.qidian.com/ajax/Search/AutoComplete
参数:query
方法:get
示例:https://www.qidian.com/ajax/Search/AutoComplete?query=惊悚
示例链接返回结果如下(因为JSON太长删掉了一部分):
{"query": "Unit","suggestions": [{"data": {"category": "书名"},"value": "惊悚乐园"}, {"data": {"category": "书名"},"value": "惊悚乐园:夜鸦"}],"code": 0
}
知道起点关键字接口的JSON数据格式后,我们就可以针对的写一个Suggestion类用于解析这串JSON数据,当然我建议直接通过在线转换工具进行转换。
/// 保存为 suggestion_model.dartclass Suggestion {String query;List<Suggestions> suggestions;int code;Suggestion({this.query, this.suggestions, this.code});Suggestion.fromJson(Map<String, dynamic> json) {query = json['query'];if (json['suggestions'] != null) {suggestions = new List<Suggestions>();json['suggestions'].forEach((v) {suggestions.add(new Suggestions.fromJson(v));});}code = json['code'];}Map<String, dynamic> toJson() {final Map<String, dynamic> data = new Map<String, dynamic>();data['query'] = this.query;if (this.suggestions != null) {data['suggestions'] = this.suggestions.map((v) => v.toJson()).toList();}data['code'] = this.code;return data;}
}class Suggestions {Data data;String value;Suggestions({this.data, this.value});Suggestions.fromJson(Map<String, dynamic> json) {data = json['data'] != null ? new Data.fromJson(json['data']) : null;value = json['value'];}Map<String, dynamic> toJson() {final Map<String, dynamic> data = new Map<String, dynamic>();if (this.data != null) {data['data'] = this.data.toJson();}data['value'] = this.value;return data;}
}class Data {String category;Data({this.category});Data.fromJson(Map<String, dynamic> json) {category = json['category'];}Map<String, dynamic> toJson() {final Map<String, dynamic> data = new Map<String, dynamic>();data['category'] = this.category;return data;}
}
上面的Suggestion模型用于解析获取到的JSON数据,接下来我们只需要实现一个用于发起请求获取数据的函数就行了,这里用到了官方的http库:dart-lang/http
/// 保存为 suggestion_api.dartimport 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;import './suggestion_model.dart';class Api {/// 关键字提示(起点)Future<List<String>> suggestion(String query) async {http.Response response = await http.get("https://www.qidian.com/ajax/Search/AutoComplete?siteid=1&query=$query");var data = Suggestion.fromJson(json.decode(response.body));List<String> suggestion = [];data.suggestions.forEach((k) {suggestion.add(k.value);});return suggestion;}
}Api api = Api();
为了演示方便所以只有一个简单的异步函数,获取到结果后返回一个包含了与关键字相关的小说列表。
正常情况下应该要将这一类的请求封装好,并与业务逻辑层分离开来。
对于一些会消耗大量资源可能会造成主线程卡顿的数据请求,比如缓存大量章节时就不要简单的使用异步了,这时应该要用isolate对数据进行处理。
二:使用Bloc模式实现业务逻辑
关于Flutter上的状态管理我就不多说了,各种各样的包都有,这里我使用的是所有Bloc包中star最多的包:felangel/bloc
选择好bloc包之后,现在可以开始实现业务逻辑中的event与state,因为只需要实现关键词提示,所以其中的事件暂时只需要一个就行。
首先要实现的是SuggestionEvent,思路与代码如下。
- SuggestionFetch:获取关键字提示的事件,其中参数 [query] 用于接收传递过来的字符串
/// 保存为 suggestion_event.dartabstract class SuggestionEvent {}class SuggestionFetch extends SuggestionEvent {final String query;SuggestionFetch({this.query});@overrideString toString() => '获取关键字提示事件';
}
实现了SuggestionEvent之后再把SuggestionState实现,思路与代码如下。
- SuggestionUninitialized:还未初始化时返回的状态。
- SuggestionLoading:表示当前正处于加载中的状态。
- SuggestionLoaded:获取关键字成功后返回的状态。
- SuggestionError:获取关键字出错时返回的状态。
/// 保存为 suggestion_state.dartabstract class SuggestionState {}class SuggestionError extends SuggestionState {@overrideString toString() => 'SuggestionError:获取失败';
}class SuggestionUninitialized extends SuggestionState {@overrideString toString() => 'SuggestionUninitialized:未初始化';
}class SuggestionLoading extends SuggestionState {@overrideString toString() => 'SuggestionLoading :正在加载';
}class SuggestionLoaded extends SuggestionState {final List<String> res;SuggestionLoaded({this.res,});@overrideString toString() => 'SuggestionLoaded:加载完毕';
}
顺带一提,之所以要重写toString,仅仅是为了后续能够看到字符串提示,你也可以不重写。
现在已经实现了一个负责事件的SuggestionEvent和一个负责状态的SuggestionState,接下来就可以实现负责管理事件与状态的SuggestionBloc了。
先说下要注意的两点:
- initialState:也就是说未接收任何事件时返回的初始状态,上面写SuggestionState时已经添加了一个状态SuggestionUninitialized表示还未初始化,这样就可以针对不同的状态改变小部件的布局,比如处于未初始化状态时可以放一个加载指示器在屏幕上。
- mapEventToState:只要接收到事件就会触发,因为写SuggestionEvent的时候只写了SuggestionFetch这一个事件,因此只需要针对SuggestionFetch进行处理就行。当触发SuggestionFetch事件之后返回一个SuggestionLoading状态表示正在加载中,然后就开始获取关键字提示列表,获取成功则返回一个包含结果列表的SuggestionLoaded状态,否则返回一个表示错误的SuggestionError状态。
/// 保存为 suggestion_bloc.dartimport 'dart:async';
import 'package:bloc/bloc.dart';
import './suggestion_api.dart';
import './suggestion_event.dart';
import './suggestion_state.dart';class SuggestionBloc extends Bloc<SuggestionEvent, SuggestionState> {@overrideSuggestionState get initialState => SuggestionUninitialized();@overrideStream<SuggestionState> mapEventToState(SuggestionState currentState,SuggestionEvent event,) async* {if (event is SuggestionFetch) {try {yield SuggestionLoading();final res = await api.suggestion(event.query);yield SuggestionLoaded(res: res);} catch (_) {yield SuggestionError();}}}
}
到此,业务逻辑层都已经实现,接下来只需要实现一下界面就可以了。
// 替换 main.dartimport 'package:flutter/material.dart';
import 'package:bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import './suggestion_bloc.dart';
import './suggestion_event.dart';
import './suggestion_state.dart';void main() {runApp(App());
}class App extends StatelessWidget {@overrideWidget build(BuildContext context) {return MaterialApp(title: '关键字提示',home: Scaffold(appBar: AppBar(title: Text('关键字提示'),),body: AppHome(),),);}
}class AppHome extends StatefulWidget {@override_AppHomeState createState() => _AppHomeState();
}class _AppHomeState extends State<AppHome> {final SuggestionBloc _suggestion = SuggestionBloc();@overrideWidget build(BuildContext context) {return Material(child: Column(children: [TextField(autofocus: true,textAlign: TextAlign.center,onSubmitted: (text) {_suggestion.dispatch(SuggestionFetch(query: text));},),Expanded(child: BlocBuilder(bloc: _suggestion,builder: (BuildContext context, SuggestionState state) {if (state is SuggestionUninitialized) {return Center(child: Text('暂无内容'),);} else if (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(title: Text(state.res[index]));},itemCount: state.res.length,);}},),),],),);}@overridevoid dispose() {_suggestion.dispose();super.dispose();}
}
界面很简单,就是一个文本输入框加上一个用于展示的列表,下面是效果图
上面的效果只是用于演示,搭配官方的搜索页面效果更好,只是懒得去弄了,后续的抽时间在写。
顺带一提,上面的目录结构只是为了演示,实际使用bloc模式的过程中最好规划好文件层次,比如使用如下目录结构:
libmain.dartblocs/suggestion/suggestion_bloc.dart
2019年3月23日 第二篇已更新
路过的冒险者:Flutter小说阅读器系列二:使用Bloc模式实现小说搜索的基本功能(略微有点长)zhuanlan.zhihu.com2019年4月6日 添加
我写文章的时候bloc版本还是0.10.0,这个版本的mapEventToState是有两个参数的,目前从bloc0.11.0开始只有一个参数了,因此只需要传入事件就行。
Stream<S> mapEventToState(S currentState, E event)
-> Stream<S> mapEventToState(E event)