📖 短故事模块
版本 PanGrowth

📖 短故事阅读器

短故事阅读器提供沉浸式阅读体验,支持章间广告、段间广告、Banner 广告等自定义广告。使用 StoryReaderNativeView 通过 PlatformView 内嵌原生阅读器,Flutter 端仅需像普通 Widget 一样使用。

✅ 基础使用

StoryReaderNativeView 通过 PlatformView 内嵌原生短故事阅读器,自动管理生命周期。

最简示例

import 'package:flutter/material.dart';
import 'package:pangrowth_content/pangrowth_content.dart';

class StoryReaderPage extends StatelessWidget {
  final int storyId;
  final String storyName;

  const StoryReaderPage({
    super.key,
    required this.storyId,
    required this.storyName,
  });

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text(storyName)),
      body: StoryReaderNativeView(
        config: StoryReaderConfig(
          storyId: storyId,
          startChapter: 0,
          defaultTextSize: 16,
        ),
      ),
    );
  }
}

监听阅读器生命周期

可通过 StoryReaderListener 获取组件创建、销毁、章节变化、广告事件等信息,便于埋点或自定义 UI。

class StoryReaderWithListenerPage extends StatefulWidget {
  final int storyId;
  final String storyName;

  const StoryReaderWithListenerPage({
    super.key,
    required this.storyId,
    required this.storyName,
  });

  @override
  State<StoryReaderWithListenerPage> createState() => _StoryReaderWithListenerPageState();
}

class _StoryReaderWithListenerPageState extends State<StoryReaderWithListenerPage> {
  String? _readerId;
  int _currentChapter = 0;
  double _readProgress = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.storyName),
        actions: [
          if (_readerId != null)
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Center(
                child: Text(
                  '第${_currentChapter + 1}${_readProgress.toStringAsFixed(0)}%',
                  style: const TextStyle(fontSize: 14),
                ),
              ),
            ),
        ],
      ),
      body: StoryReaderNativeView(
        config: StoryReaderConfig(
          storyId: widget.storyId,
          startChapter: _currentChapter,
          defaultTextSize: 16,
        ),
        listener: StoryReaderListener(
          onReaderReady: (readerId) {
            debugPrint('阅读器就绪: $readerId');
            setState(() => _readerId = readerId);
          },
          onReaderDisposed: () {
            debugPrint('阅读器已释放');
            setState(() => _readerId = null);
          },
          onChapterChanged: (chapter) {
            debugPrint('切换到章节: $chapter');
            setState(() => _currentChapter = chapter);
            // 记录阅读进度
            _saveProgress(widget.storyId, chapter);
          },
          onProgressChanged: (progress) {
            setState(() => _readProgress = progress);
          },
          onFavoriteChanged: (isFavorite) {
            debugPrint('收藏状态变化: $isFavorite');
          },
          onMiddlePageAdShow: () {
            debugPrint('章间广告显示');
          },
          onMiddleLineAdShow: (lineNumber) {
            debugPrint('段间广告显示在第$lineNumber行');
          },
          onBannerAdShow: () {
            debugPrint('Banner广告显示');
          },
          onReaderError: (error) {
            debugPrint('阅读器加载失败: $error');
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(
                content: Text('阅读器加载失败,请稍后重试'),
                action: SnackBarAction(
                  label: '确定',
                  onPressed: () {},
                ),
              ),
            );
          },
        ),
      ),
    );
  }

  Future<void> _saveProgress(int storyId, int chapter) async {
    // 保存阅读进度到本地或服务器
    debugPrint('保存进度: storyId=$storyId, chapter=$chapter');
  }
}

生命周期与限制

  • 自动管理: StoryReaderNativeViewdispose 阶段会自动调用 PangrowthContent.destroyStoryReader,无需手动管理。
  • 平台视图限制: PlatformView 由原生渲染,避免在其上方叠加 Positioned/Stack 等复杂布局,必要时可在外层使用安全区域、背景容器。
  • 事件监听: 通过 listener 参数注册事件监听器,在组件销毁时会自动取消注册。

🧩 配置参数(StoryReaderConfig)

基础配置

参数类型必填平台支持说明默认值
storyIdint✅ 双端短故事 ID-
startChapterint?✅ 双端起始章节索引,从 0 开始0
defaultTextSizeint?✅ 双端默认字体大小SDK默认
endPageRecSizeint?✅ 双端文末推荐数量SDK默认
hideBackbool?✅ 双端是否隐藏返回按钮false
pageTurnTypeint?🍎 iOS翻页模式: 0仿真/1滑页/2平移/3上下滚动SDK默认
fontSizeLevelint?🍎 iOS字体大小档位SDK默认

自定义广告配置

阅读器支持三种自定义广告类型:

1. 章间广告

在章节切换时展示的全屏广告。

参数类型说明默认值
customMiddlePageAdViewIdString?章间广告视图 IDnull
middlePageIntervalint?章间广告插入间隔(章节数)SDK默认

2. 段间广告

在阅读内容中间插入的原生广告。

参数类型说明默认值
customMiddleLineAdViewIdString?段间广告视图 IDnull
middleLineStartLineint?段间广告起始行数SDK默认
middleLineIntervalint?段间广告插入间隔(行数)SDK默认

3. Banner 广告

固定在底部的横幅广告。

参数类型说明默认值
customBannerAdViewIdString?Banner 广告视图 IDnull

💰 自定义广告示例

完整广告配置

StoryReaderNativeView(
  config: StoryReaderConfig(
    storyId: 67890,
    startChapter: 0,
    defaultTextSize: 16,
    endPageRecSize: 6,         // 文末推荐6个短故事
    pageTurnType: 1,           // 滑页模式(iOS)
    fontSizeLevel: 2,          // 字体大小档位(iOS)

    // 配置章间广告
    customMiddlePageAdViewId: 'story_page_ad',
    middlePageInterval: 5, // 每 5 章插入一次

    // 配置段间广告
    customMiddleLineAdViewId: 'story_line_ad',
    middleLineStartLine: 10,  // 从第 10 行开始插入
    middleLineInterval: 20,    // 每 20 行插入一次

    // 配置 Banner 广告
    customBannerAdViewId: 'story_banner_ad',
  ),
  listener: StoryReaderListener(
    onMiddlePageAdShow: () {
      debugPrint('章间广告显示');
      // 记录广告曝光
    },
    onMiddlePageAdClick: () {
      debugPrint('章间广告点击');
      // 记录广告点击
    },
    onMiddlePageAdClose: () {
      debugPrint('章间广告关闭');
    },
    onMiddleLineAdShow: (lineNumber) {
      debugPrint('段间广告显示在第$lineNumber行');
    },
    onMiddleLineAdClick: () {
      debugPrint('段间广告点击');
    },
    onBannerAdShow: () {
      debugPrint('Banner广告显示');
    },
    onBannerAdClick: () {
      debugPrint('Banner广告点击');
    },
  ),
)

仅章间广告

StoryReaderNativeView(
  config: StoryReaderConfig(
    storyId: 67890,
    customMiddlePageAdViewId: 'story_page_ad',
    middlePageInterval: 3, // 每 3 章插入一次章间广告
  ),
)

仅 Banner 广告

StoryReaderNativeView(
  config: StoryReaderConfig(
    storyId: 67890,
    customBannerAdViewId: 'story_banner_ad',
  ),
)

📡 事件监听

组件级事件(StoryReaderListener)

组件级事件通过 StoryReaderListener 管理:

回调方法参数说明
onReaderReadyString readerId阅读器创建完成并准备就绪
onReaderDisposed-阅读器销毁
onReaderErrorString error阅读器发生错误
onChapterChangedint chapter章节切换(章节索引从0开始)
onProgressChangeddouble progress阅读进度变化(0-100)
onFavoriteChangedbool isFavorite收藏状态变化
onMiddlePageAdShow-章间广告显示
onMiddlePageAdClick-章间广告点击
onMiddlePageAdClose-章间广告关闭
onMiddleLineAdShowint lineNumber段间广告显示(行号)
onMiddleLineAdClick-段间广告点击
onBannerAdShow-Banner广告显示
onBannerAdClick-Banner广告点击

🌍 平台差异与适配建议

字体大小

  • 双端支持: defaultTextSize 参数在 Android 和 iOS 都支持
  • 建议范围: 12-24
  • 默认值: SDK 内部默认,通常为 16

返回按钮

  • 默认行为: 显示原生返回按钮,点击后关闭阅读器
  • 自定义: 设置 hideBack: true 后,需在 Flutter 层提供自定义返回逻辑
  • 注意: 隐藏返回按钮后,务必提供其他退出方式(如 AppBar 返回按钮)

广告配置

  • 通用性: 所有自定义广告参数在双端通用
  • 前提条件: 需先在对应平台配置广告位 ID
  • GroMore SDK: 确保 GroMore 广告 SDK 已正确初始化

💡 使用场景示例

场景1: 带进度记录的智能阅读器

class SmartStoryReaderPage extends StatefulWidget {
  final int storyId;
  final String storyName;
  final int? lastReadChapter;

  const SmartStoryReaderPage({
    super.key,
    required this.storyId,
    required this.storyName,
    this.lastReadChapter,
  });

  @override
  State<SmartStoryReaderPage> createState() => _SmartStoryReaderPageState();
}

class _SmartStoryReaderPageState extends State<SmartStoryReaderPage> {
  late int _currentChapter;
  double _readProgress = 0;

  @override
  void initState() {
    super.initState();
    _currentChapter = widget.lastReadChapter ?? 0;
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(widget.storyName, style: const TextStyle(fontSize: 16)),
            Text(
              '第${_currentChapter + 1}章',
              style: const TextStyle(fontSize: 12, color: Colors.grey),
            ),
          ],
        ),
        actions: [
          // 进度指示器
          Padding(
            padding: const EdgeInsets.symmetric(horizontal: 16),
            child: Center(
              child: Text(
                '${_readProgress.toStringAsFixed(0)}%',
                style: const TextStyle(fontSize: 14),
              ),
            ),
          ),
        ],
      ),
      body: StoryReaderNativeView(
        config: StoryReaderConfig(
          storyId: widget.storyId,
          startChapter: _currentChapter,
          defaultTextSize: 16,
        ),
        listener: StoryReaderListener(
          onChapterChanged: (chapter) {
            setState(() => _currentChapter = chapter);
            // 保存阅读进度到本地
            _saveProgress(widget.storyId, chapter);
          },
          onProgressChanged: (progress) {
            setState(() => _readProgress = progress);
          },
        ),
      ),
    );
  }

  Future<void> _saveProgress(int storyId, int chapter) async {
    // 使用 SharedPreferences 或数据库保存进度
    debugPrint('保存进度: storyId=$storyId, chapter=$chapter');
  }
}

场景2: 完整商业化配置

class MonetizedStoryReaderPage extends StatefulWidget {
  final int storyId;
  final String storyName;

  const MonetizedStoryReaderPage({
    super.key,
    required this.storyId,
    required this.storyName,
  });

  @override
  State<MonetizedStoryReaderPage> createState() => _MonetizedStoryReaderPageState();
}

class _MonetizedStoryReaderPageState extends State<MonetizedStoryReaderPage> {
  int _adImpressions = 0;
  int _adClicks = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.storyName),
        actions: [
          // 广告统计(调试用)
          if (kDebugMode)
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16),
              child: Center(
                child: Text(
                  '广告: $_adImpressions / $_adClicks',
                  style: const TextStyle(fontSize: 12),
                ),
              ),
            ),
        ],
      ),
      body: StoryReaderNativeView(
        config: StoryReaderConfig(
          storyId: widget.storyId,
          startChapter: 0,
          defaultTextSize: 16,

          // 章间广告:每 3 章一次
          customMiddlePageAdViewId: 'story_chapter_ad',
          middlePageInterval: 3,

          // 段间广告:从第 10 行开始,每 15 行一次
          customMiddleLineAdViewId: 'story_paragraph_ad',
          middleLineStartLine: 10,
          middleLineInterval: 15,

          // 底部 Banner
          customBannerAdViewId: 'story_banner_ad',
        ),
        listener: StoryReaderListener(
          // 章间广告事件
          onMiddlePageAdShow: () {
            setState(() => _adImpressions++);
            _trackAdEvent('chapter_ad', 'impression');
          },
          onMiddlePageAdClick: () {
            setState(() => _adClicks++);
            _trackAdEvent('chapter_ad', 'click');
          },
          onMiddlePageAdClose: () {
            _trackAdEvent('chapter_ad', 'close');
          },

          // 段间广告事件
          onMiddleLineAdShow: (lineNumber) {
            setState(() => _adImpressions++);
            _trackAdEvent('paragraph_ad', 'impression', line: lineNumber);
          },
          onMiddleLineAdClick: () {
            setState(() => _adClicks++);
            _trackAdEvent('paragraph_ad', 'click');
          },

          // Banner广告事件
          onBannerAdShow: () {
            setState(() => _adImpressions++);
            _trackAdEvent('banner_ad', 'impression');
          },
          onBannerAdClick: () {
            setState(() => _adClicks++);
            _trackAdEvent('banner_ad', 'click');
          },
        ),
      ),
    );
  }

  void _trackAdEvent(String adType, String action, {int? line}) {
    // 广告事件埋点
    debugPrint('广告事件: type=$adType, action=$action, line=$line');
    // 上报到分析平台
  }
}

场景3: Controller 方式控制阅读器

class ControlledStoryReaderPage extends StatefulWidget {
  final int storyId;
  final String storyName;

  const ControlledStoryReaderPage({
    super.key,
    required this.storyId,
    required this.storyName,
  });

  @override
  State<ControlledStoryReaderPage> createState() => _ControlledStoryReaderPageState();
}

class _ControlledStoryReaderPageState extends State<ControlledStoryReaderPage> {
  final StoryReaderController _controller = StoryReaderController();
  int _currentChapter = 0;

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  Future<void> _jumpToChapter(int chapter) async {
    // 使用 controller 跳转到指定章节
    await _controller.setChapter(chapter);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.storyName),
        actions: [
          // 章节选择按钮
          IconButton(
            icon: const Icon(Icons.list),
            onPressed: () {
              showDialog(
                context: context,
                builder: (context) => AlertDialog(
                  title: const Text('跳转到章节'),
                  content: SizedBox(
                    width: double.maxFinite,
                    child: ListView.builder(
                      shrinkWrap: true,
                      itemCount: 50, // 假设有50章
                      itemBuilder: (context, index) {
                        return ListTile(
                          title: Text('第${index + 1}章'),
                          selected: index == _currentChapter,
                          onTap: () {
                            Navigator.pop(context);
                            _jumpToChapter(index);
                          },
                        );
                      },
                    ),
                  ),
                ),
              );
            },
          ),
        ],
      ),
      body: StoryReaderNativeView(
        config: StoryReaderConfig(
          storyId: widget.storyId,
          startChapter: _currentChapter,
          defaultTextSize: 16,
        ),
        controller: _controller,
        listener: StoryReaderListener(
          onChapterChanged: (chapter) {
            setState(() => _currentChapter = chapter);
          },
        ),
      ),
    );
  }
}

场景4: 自定义返回逻辑

class CustomBackStoryReaderPage extends StatelessWidget {
  final int storyId;
  final String storyName;

  const CustomBackStoryReaderPage({
    super.key,
    required this.storyId,
    required this.storyName,
  });

  @override
  Widget build(BuildContext context) {
    return PopScope(
      canPop: false,
      onPopInvoked: (didPop) {
        if (!didPop) {
          // 显示确认对话框
          showDialog(
            context: context,
            builder: (context) => AlertDialog(
              title: const Text('确认退出'),
              content: const Text('您确定要退出阅读吗?\n当前阅读进度会自动保存。'),
              actions: [
                TextButton(
                  onPressed: () => Navigator.pop(context),
                  child: const Text('继续阅读'),
                ),
                TextButton(
                  onPressed: () {
                    Navigator.pop(context);
                    Navigator.pop(context);
                  },
                  child: const Text('确定退出'),
                ),
              ],
            ),
          );
        }
      },
      child: Scaffold(
        body: StoryReaderNativeView(
          config: StoryReaderConfig(
            storyId: storyId,
            hideBack: true,  // 隐藏原生返回按钮
            defaultTextSize: 16,
          ),
        ),
      ),
    );
  }
}

场景5: 状态管理 + 阅读器

// 使用 Provider 管理阅读器状态
class StoryReaderProvider extends ChangeNotifier {
  String? _readerId;
  int _currentChapter = 0;
  double _readProgress = 0;
  bool _isFavorite = false;

  String? get readerId => _readerId;
  int get currentChapter => _currentChapter;
  double get readProgress => _readProgress;
  bool get isFavorite => _isFavorite;

  void onReaderReady(String readerId) {
    _readerId = readerId;
    notifyListeners();
  }

  void onChapterChanged(int chapter) {
    _currentChapter = chapter;
    notifyListeners();
  }

  void onProgressChanged(double progress) {
    _readProgress = progress;
    notifyListeners();
  }

  void onFavoriteChanged(bool isFavorite) {
    _isFavorite = isFavorite;
    notifyListeners();
  }

  void onReaderDisposed() {
    _readerId = null;
    notifyListeners();
  }
}

// 页面中使用
class StoryReaderWithProviderPage extends StatelessWidget {
  final int storyId;
  final String storyName;

  const StoryReaderWithProviderPage({
    super.key,
    required this.storyId,
    required this.storyName,
  });

  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (_) => StoryReaderProvider(),
      child: Scaffold(
        appBar: AppBar(
          title: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(storyName, style: const TextStyle(fontSize: 16)),
              Consumer<StoryReaderProvider>(
                builder: (context, provider, _) {
                  return Text(
                    '第${provider.currentChapter + 1}${provider.readProgress.toStringAsFixed(0)}%',
                    style: const TextStyle(fontSize: 12, color: Colors.grey),
                  );
                },
              ),
            ],
          ),
          actions: [
            Consumer<StoryReaderProvider>(
              builder: (context, provider, _) {
                return IconButton(
                  icon: Icon(
                    provider.isFavorite ? Icons.favorite : Icons.favorite_border,
                    color: provider.isFavorite ? Colors.red : null,
                  ),
                  onPressed: () {
                    // 切换收藏状态
                  },
                );
              },
            ),
          ],
        ),
        body: Consumer<StoryReaderProvider>(
          builder: (context, provider, _) {
            return StoryReaderNativeView(
              config: StoryReaderConfig(
                storyId: storyId,
                startChapter: provider.currentChapter,
                defaultTextSize: 16,
              ),
              listener: StoryReaderListener(
                onReaderReady: provider.onReaderReady,
                onChapterChanged: provider.onChapterChanged,
                onProgressChanged: provider.onProgressChanged,
                onFavoriteChanged: provider.onFavoriteChanged,
                onReaderDisposed: provider.onReaderDisposed,
              ),
            );
          },
        ),
      ),
    );
  }
}

⚠️ 常见问题排查

问题1: 阅读器无法显示

症状: StoryReaderNativeView 渲染黑屏或空白

可能原因:

  1. SDK 未正确初始化(PangrowthContent.initialize)
  2. SDK 未启动(PangrowthContent.startstartStory: true)
  3. GroMore 广告SDK 未初始化
  4. 故事 ID 不存在或无效

解决方法:

// 确保初始化顺序正确
// 1. 初始化 GroMore SDK
await GroMore.initialize(...);

// 2. 初始化 PangrowthContent
await PangrowthContent.initialize(
  configPath: 'assets/config.json',
);

// 3. 启动短故事模块
await PangrowthContent.start(startStory: true);

// 4. 使用 StoryReaderNativeView

问题2: 章节跳转不生效

症状: 设置 startChapter 参数后,阅读器仍从第一章开始

排查步骤:

// 确保 config 正确传递
StoryReaderNativeView(
  config: StoryReaderConfig(
    storyId: 123,
    startChapter: 5,  // 确保值正确(从0开始)
  ),
  listener: StoryReaderListener(
    onChapterChanged: (chapter) {
      print('当前章节: $chapter');  // 检查实际章节
    },
  ),
)

问题3: 广告未显示

症状: 配置了自定义广告但未显示

可能原因:

  • 广告位 ID 配置错误
  • GroMore SDK 未正确初始化
  • 广告间隔配置不合理

解决方法:

// 1. 确认广告位 ID 正确
StoryReaderNativeView(
  config: StoryReaderConfig(
    storyId: 123,
    customBannerAdViewId: 'story_banner_ad',  // 确保ID与平台配置一致
  ),
  listener: StoryReaderListener(
    onBannerAdShow: () {
      print('Banner广告显示成功');  // 验证回调触发
    },
  ),
)

// 2. 检查广告SDK初始化
// 确保 GroMore SDK 已正确初始化并配置广告位

问题4: 隐藏返回按钮后无法退出

症状: 设置 hideBack: true 后,用户无法退出阅读器

解决方法: 必须提供替代的退出方式

Scaffold(
  appBar: AppBar(
    leading: IconButton(
      icon: const Icon(Icons.arrow_back),
      onPressed: () => Navigator.pop(context),
    ),
    title: const Text('阅读器'),
  ),
  body: StoryReaderNativeView(
    config: StoryReaderConfig(
      storyId: 123,
      hideBack: true,  // 隐藏原生返回按钮
    ),
  ),
)

问题5: 字体大小设置不生效

症状: 设置 defaultTextSize 参数后,字体大小未改变

排查步骤:

// 确保值在合理范围内(12-24)
StoryReaderNativeView(
  config: StoryReaderConfig(
    storyId: 123,
    defaultTextSize: 18,  // 建议范围 12-24
  ),
)

// 如果仍不生效,检查是否被用户设置覆盖
// 原生SDK可能允许用户在阅读器内调整字体大小

🔗 相关文档


💡 提示: 短故事阅读器需要有效的短故事内容源。建议合理配置广告参数(如章间广告间隔不宜过小),以平衡用户体验和商业收益。阅读进度建议实时保存,以便用户下次打开时能从上次位置继续阅读。

需要进一步协助?

与 LightCore 技术顾问沟通,获取商业化策略与集成支持。