📖 短故事模块
版本 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');
}
}
生命周期与限制
- 自动管理:
StoryReaderNativeView
在dispose
阶段会自动调用PangrowthContent.destroyStoryReader
,无需手动管理。 - 平台视图限制: PlatformView 由原生渲染,避免在其上方叠加
Positioned
/Stack
等复杂布局,必要时可在外层使用安全区域、背景容器。 - 事件监听: 通过
listener
参数注册事件监听器,在组件销毁时会自动取消注册。
🧩 配置参数(StoryReaderConfig
)
基础配置
参数 | 类型 | 必填 | 平台支持 | 说明 | 默认值 |
---|---|---|---|---|---|
storyId | int | 是 | ✅ 双端 | 短故事 ID | - |
startChapter | int? | 否 | ✅ 双端 | 起始章节索引,从 0 开始 | 0 |
defaultTextSize | int? | 否 | ✅ 双端 | 默认字体大小 | SDK默认 |
endPageRecSize | int? | 否 | ✅ 双端 | 文末推荐数量 | SDK默认 |
hideBack | bool? | 否 | ✅ 双端 | 是否隐藏返回按钮 | false |
pageTurnType | int? | 否 | 🍎 iOS | 翻页模式: 0仿真/1滑页/2平移/3上下滚动 | SDK默认 |
fontSizeLevel | int? | 否 | 🍎 iOS | 字体大小档位 | SDK默认 |
自定义广告配置
阅读器支持三种自定义广告类型:
1. 章间广告
在章节切换时展示的全屏广告。
参数 | 类型 | 说明 | 默认值 |
---|---|---|---|
customMiddlePageAdViewId | String? | 章间广告视图 ID | null |
middlePageInterval | int? | 章间广告插入间隔(章节数) | SDK默认 |
2. 段间广告
在阅读内容中间插入的原生广告。
参数 | 类型 | 说明 | 默认值 |
---|---|---|---|
customMiddleLineAdViewId | String? | 段间广告视图 ID | null |
middleLineStartLine | int? | 段间广告起始行数 | SDK默认 |
middleLineInterval | int? | 段间广告插入间隔(行数) | SDK默认 |
3. Banner 广告
固定在底部的横幅广告。
参数 | 类型 | 说明 | 默认值 |
---|---|---|---|
customBannerAdViewId | String? | Banner 广告视图 ID | null |
💰 自定义广告示例
完整广告配置
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
管理:
回调方法 | 参数 | 说明 |
---|---|---|
onReaderReady | String readerId | 阅读器创建完成并准备就绪 |
onReaderDisposed | - | 阅读器销毁 |
onReaderError | String error | 阅读器发生错误 |
onChapterChanged | int chapter | 章节切换(章节索引从0开始) |
onProgressChanged | double progress | 阅读进度变化(0-100) |
onFavoriteChanged | bool isFavorite | 收藏状态变化 |
onMiddlePageAdShow | - | 章间广告显示 |
onMiddlePageAdClick | - | 章间广告点击 |
onMiddlePageAdClose | - | 章间广告关闭 |
onMiddleLineAdShow | int 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
渲染黑屏或空白
可能原因:
- SDK 未正确初始化(
PangrowthContent.initialize
) - SDK 未启动(
PangrowthContent.start
且startStory: true
) - GroMore 广告SDK 未初始化
- 故事 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可能允许用户在阅读器内调整字体大小
🔗 相关文档
- 短故事聚合页 - 聚合页组件,跳转到阅读器
- 短故事信息接口 - 信息查询 API
- 自定义广告完整指南 - 广告配置详解
- 事件处理 - 完整的事件体系说明
- Widget组件总览 - 所有组件导航
💡 提示: 短故事阅读器需要有效的短故事内容源。建议合理配置广告参数(如章间广告间隔不宜过小),以平衡用户体验和商业收益。阅读进度建议实时保存,以便用户下次打开时能从上次位置继续阅读。