分类
联系方式
  1. 新浪微博
  2. E-mail

Fluent-reader-lite 源码分析

介绍

yang991178/fluent-reader-lite: Simplistic mobile RSS client built with Flutter》是一个用 Flutter 开发的 RSS 阅读器。

Fluent Reader 分为桌面端和移动端:

两个项目都开源,采用《BSD 3-Clause "New" or "Revised" License》开源许可证。同时两个项目也都上架了应用商店,下载量不少。

在本文中,我将对移动端的 Fluent-reader-lite 项目进行分析研究。

项目代码总共 6.7k 行 Dart 代码,规模不大。

开源盈利模式

该项目采用:代码开源,GitHub Release 免费下载,应用商店付费下载的开源盈利方式。

Windows 商店 ¥29 元,Android 和 iOS 商店 1.99 刀。因为可以免费下载,商店售卖其实是一种赞助支持的方式。

商店版能够自动更新版本,也许免费下载版需要手动下载更新版本,这也是一点体验上的区别。

我觉得这种模式不错,值得借鉴。再加上一个捐赠渠道,就比较完整了。

技术栈选择

作者桌面端和移动端选用了不同的技术栈,是非常明智的。

当然,基于项目的背景,很可能是桌面端用前端技术栈先开发成功了,后面移动端选择一个新技术栈尝尝鲜,毕竟移动端上也没有 Electron 嘛。

回到技术上来,Flutter 在桌面端没有成熟的 WebView 支持,这成了限制 Flutter 在桌面平台发展的因素。

目前,Flutter 在 Windows 和 macOS 下只有一些第三方的简单封装,WebView2(Windows),WKWebView(macOS),还过于简陋。

因此,作者在不同平台下,都选择了 Web 能力强的框架,能够满足 RSS 阅读器的技术需求。

数据库

采用 SQLite 数据库。

数据表

数据表比我预想中要简单的多,2张表搞定:

_onCreate(Database db, int version) async {\n await db.execute('''\n CREATE TABLE sources (\n sid TEXT PRIMARY KEY,\n url TEXT NOT NULL,\n iconUrl TEXT,\n name TEXT NOT NULL,\n openTarget INTEGER NOT NULL,\n latest INTEGER NOT NULL,\n lastTitle INTEGER NOT NULL\n );\n ''');\n await db.execute('''\n CREATE TABLE items (\n iid TEXT PRIMARY KEY,\n source TEXT NOT NULL,\n title TEXT NOT NULL,\n link TEXT NOT NULL,\n date INTEGER NOT NULL,\n content TEXT NOT NULL,\n snippet TEXT NOT NULL,\n hasRead INTEGER NOT NULL,\n starred INTEGER NOT NULL,\n creator TEXT,\n thumb TEXT\n );\n ''');\n await db.execute(\"CREATE INDEX itemsDate ON items (date DESC);\");\n}\n"}}" data-parsoid="{"dsr":[1162,1965,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
static Future<void> _onCreate(Database db, int version) async {
  await db.execute('''
    CREATE TABLE sources (
      sid TEXT PRIMARY KEY,
      url TEXT NOT NULL,
      iconUrl TEXT,
      name TEXT NOT NULL,
      openTarget INTEGER NOT NULL,
      latest INTEGER NOT NULL,
      lastTitle INTEGER NOT NULL
    );
  ''');
  await db.execute('''
  CREATE TABLE items (
      iid TEXT PRIMARY KEY,
      source TEXT NOT NULL,
      title TEXT NOT NULL,
      link TEXT NOT NULL,
      date INTEGER NOT NULL,
      content TEXT NOT NULL,
      snippet TEXT NOT NULL,
      hasRead INTEGER NOT NULL,
      starred INTEGER NOT NULL,
      creator TEXT,
      thumb TEXT
    );
  ''');
  await db.execute("CREATE INDEX itemsDate ON items (date DESC);");
}

页面列表

一共十几个页面:

baseRoutes = {\n \"/article\": (context) => ArticlePage(),\n \"/error-log\": (context) => ErrorLogPage(),\n \"/settings\": (context) => SettingsPage(),\n \"/settings/sources\": (context) => SourcesPage(),\n \"/settings/sources/edit\": (context) => SourceEditPage(),\n \"/settings/feed\": (context) => FeedPage(),\n \"/settings/reading\": (context) => ReadingPage(),\n \"/settings/general\": (context) => GeneralPage(),\n \"/settings/about\": (context) => AboutPage(),\n \"/settings/service/fever\": (context) => FeverPage(),\n \"/settings/service/feedbin\": (context) => FeedbinPage(),\n \"/settings/service/inoreader\": (context) => InoreaderPage(),\n \"/settings/service/greader\": (context) => GReaderPage(),\n \"/settings/service\": (context) {\n"}}" data-parsoid="{"dsr":[1986,2811,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
static final Map<String, Widget Function(BuildContext)> baseRoutes = {
  "/article": (context) => ArticlePage(),
  "/error-log": (context) => ErrorLogPage(),
  "/settings": (context) => SettingsPage(),
  "/settings/sources": (context) => SourcesPage(),
  "/settings/sources/edit": (context) => SourceEditPage(),
  "/settings/feed": (context) => FeedPage(),
  "/settings/reading": (context) => ReadingPage(),
  "/settings/general": (context) => GeneralPage(),
  "/settings/about": (context) => AboutPage(),
  "/settings/service/fever": (context) => FeverPage(),
  "/settings/service/feedbin": (context) => FeedbinPage(),
  "/settings/service/inoreader": (context) => InoreaderPage(),
  "/settings/service/greader": (context) => GReaderPage(),
  "/settings/service": (context) {

状态管理结构

采用 Provider 进行状态管理。

Global 全局单例

Global 中保存了所有的全局服务:

tabletPanel = GlobalKey();\n"}}" data-parsoid="{"dsr":[2886,3339,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
abstract class Global {
  static bool _initialized = false;
  static GlobalModel globalModel;
  static SourcesModel sourcesModel;
  static ItemsModel itemsModel;
  static FeedsModel feedsModel;
  static GroupsModel groupsModel;
  static SyncModel syncModel;
  static ServiceHandler service;
  static Database db;
  static Jaguar server;
  static final GlobalKey<NavigatorState> tabletPanel = GlobalKey();

其中:

  1. Global 本身是个抽象类:比较巧妙,不是为了继承,是为了防止创建单例
  2. 里面全是静态成员和静态方法
  3. 有一个 init 方法用于初始化

项目中:

  • 服务(Service)的含义是指线上的资讯服务 Fever、Feedbin、GReader、Inoreader
  • App 的服务层命名为 model,每个 model 都继承自 ChangeNotifier。

Provider 状态提供

@override
Widget build(BuildContext context) {
  return MultiProvider(
    providers: [
      ChangeNotifierProvider.value(value: Global.globalModel),
      ChangeNotifierProvider.value(value: Global.sourcesModel),
      ChangeNotifierProvider.value(value: Global.itemsModel),
      ChangeNotifierProvider.value(value: Global.feedsModel),
      ChangeNotifierProvider.value(value: Global.groupsModel),
      ChangeNotifierProvider.value(value: Global.syncModel),
    ],

Model 与 SP 打通

配置信息由 SharedPreference 提供,并通过 Provider 直接响应式通知出去:

_theme;\n set theme(ThemeSetting value) {\n if (value != _theme) {\n _theme = value;\n notifyListeners();\n Store.setTheme(value);\n }\n }\n"}}" data-parsoid="{"dsr":[4147,4794,2,2]}" dir="ltr" typeof="mw:Extension/syntaxhighlight">
class GlobalModel with ChangeNotifier {
  ThemeSetting _theme = Store.getTheme();
  Locale _locale = Store.getLocale();
  int _keepItemsDays = Store.sp.getInt(StoreKeys.KEEP_ITEMS_DAYS) ?? 21;
  bool _syncOnStart = Store.sp.getBool(StoreKeys.SYNC_ON_START) ?? true;
  bool _inAppBrowser = Store.sp.getBool(StoreKeys.IN_APP_BROWSER) ?? Platform.isIOS;
  double _textScale = Store.sp.getDouble(StoreKeys.TEXT_SCALE);

  ThemeSetting get theme => _theme;
  set theme(ThemeSetting value) {
    if (value != _theme) {
      _theme = value;
      notifyListeners();
      Store.setTheme(value);
    }
  }

Jaguar HTTP Server

项目中内置了一个 HTTP Server,使用了开源库 Jaguar-dart/jaguar: Jaguar, a server framework built for speed, simplicity and extensible. ORM, Session, Authentication & Authorization, OAuth

项目中包含前端网页,HTTP Server 作为静态服务器进行托管。

静态网页位于 assets 目录下。基于 Raynos/mercury: A truly modular frontend framework JavaScript 框架。

静态页是 article.html,主要用于文章展示。

同步服务

还支持与外部服务同步,包括 Fever、Feedbin、GReader、Inoreader。

采用代理模式,接口抽象地很清晰。