主题
叙事植入 —— NovvyInsertAd
一集内自动串行插播多支广告,广告在宿主播放器视图内部展示(不起 UIViewController、无窗口转场)。每集一个实例,宿主只需 attach() 一次并在切集时告知哪一集在屏幕上,其余(请求、预加载、到点暂停、展示、关闭、恢复播放、请求下一支)全部由 SDK 完成。
宿主的进度条/点赞等 UI 可以继续浮在广告之上,无需隐藏。
两条播放器接入路径
默认路径使用原生 AVFoundation.AVPlayer,即 attach(player:playerView:)。使用第三方播放器(TXVodPlayer / AliPlayer / IJKPlayer / 自研包装)的宿主,改传 NovvyStoryPlayerAdapter,见下方「第三方播放器接入」小节。
对接流程
Episode starts(本集开始播放)
↓
setContentContext(...) // series 级信息(剧名等)
↓
NovvyInsertAd(adUnitId:episode:) + attach(player:playerView:) // 只绑定,不请求
↓
切到本集时 → setActive(true) // 从这里开始,SDK 自动跑
↓
insertAdDidLoad → insertAdDidShow → insertAdDidClose // 每支广告一轮,自动重复
↓
insertAdChainDidFinish // 本集广告结束
↓
页面回收 → destroy()接入代码
swift
// 每集一个实例;episode 传本实例代表的集数
let insertAd = NovvyInsertAd(adUnitId: "your_ad_unit_id", episode: 3)
insertAd.delegate = self
// 绑定宿主的 AVPlayer 与承载视频画面的容器(只绑定,不发请求)
insertAd.attach(player: avPlayer, playerView: playerView)
// MARK: - NovvyInsertAdDelegate
func insertAdDidLoad(_ ad: NovvyInsertAd) { /* 一支广告已就绪 */ }
func insertAd(_ ad: NovvyInsertAd, didFailToLoadWithError error: NovvyError) { /* ... */ }
func insertAdDidShow(_ ad: NovvyInsertAd) { /* 已展示(播放器已暂停) */ }
func insertAdDidClose(_ ad: NovvyInsertAd) { /* 已关闭(播放器已恢复) */ }
func insertAdChainDidFinish(_ ad: NovvyInsertAd) { /* 本集广告全部结束 */ }
// 切集:只对新的一集调 true,旧的会自动停止(无需对旧实例配对 setActive(false))
insertAd.setActive(true)
// 页面被回收时释放
insertAd.destroy()参数
attach() 只做绑定、不发请求,可在页面创建时就调用,不必等播放器加载完成。
| 参数 | 说明 |
|---|---|
adUnitId | story_placement 类型的广告位标识 |
episode | 本实例代表的集数,请显式传入 —— 覆盖 setContentContext(...) 的集数,剧名等 series 级信息仍取自上下文 |
player | 宿主的 AVPlayer |
playerView | 承载视频画面的 UIView,广告作为子视图铺满它。传视频画面实际占据的那一层 —— 外面若还套了别的视图,不要传外层;接了 AVPlayerLayer 的那个 view 直接传它 |
delegate | 可选,不需要回调时可不设 |
播放器由 SDK 控制
广告展示期间 SDK 会暂停宿主播放器、关闭后自动恢复,宿主无需自行 play() / pause()。两个例外:
- 剧尾广告关闭后保持暂停,由用户自己滑到下一集
- 宿主在广告展示期间
destroy()了实例,播放权交回宿主,需自行play()
宿主要求
| 要求 | 说明 |
|---|---|
| 播放器不要循环播放 | AVPlayer.actionAtItemEnd 保持 .pause(默认值)。用 AVPlayerLooper 或 .none + 手动重播时播放器不会走到片尾,剧尾广告无法触发 |
切集时只对新的一集调 setActive(true) | 旧实例自动停止;滑回上一集时广告会自动恢复。手动配对 setActive(false) 也安全(SwiftUI 里 .onChange { setActive(newValue == index) } 这种对称写法即可),不会弄丢 standby 里保活的邻集广告 |
| 回收可视范围外的实例 | 照 UICollectionView / UIPageViewController 回收 cell 的方式 destroy()。至少保留相邻一集 —— 否则跨集回看(1→2→1 恢复上一集广告)会静默失效,SDK 只在 os_log 打 warning |
| 右上角留出安全区 | SDK 的关闭药丸在右上角,约 30×30pt |
| 广告展示期间禁用自己的进度拖动与单击暂停 | 避免与 SDK 的暂停状态冲突。SDK 内部有位置锁兜底(拖走会弹回、被 play() 会重新按停),但双方仍应配合 |
| 播放器在主线程创建 | AVFoundation 主线程亲和的操作要用到 |
第三方播放器接入 —— NovvyStoryPlayerAdapter
上面默认你手里是 AVPlayer。使用第三方播放器的宿主实现这个协议,SDK 就能在它上面跑完全相同的广告链。宿主只负责「如实回答 + 老实执行」,采样、定时、到点暂停、播完兜底、广告期间的锁纠正全在 SDK 内部。
swift
// 六个成员:currentPositionMs / isPlaying 继承自暂停卡的 NovvyPlayerAdapter,本协议再加四个
final class MyStoryPlayerAdapter: NovvyStoryPlayerAdapter {
let player: TXVodPlayer
// ⚠️ 同步读,不是「上一次进度回调缓存的值」—— 见下面宿主要求第 3、4 条
var currentPositionMs: Int64 { Int64(player.currentPlaybackTime * 1000) }
var isPlaying: Bool { player.isPlaying() }
var durationMs: Int64 { Int64(player.duration * 1000) }
func pause() { player.pause() }
func play() { player.resume() }
func seekTo(positionMs: Int64) { player.seek(Float(positionMs) / 1000) }
}
let adapter = MyStoryPlayerAdapter(player: myPlayer) // ⚠️ SDK 只弱引用,宿主自己强持有到 destroy()
insertAd.attach(adapter: adapter, playerView: playerView)同一个 adapter 传给 pager 里多个 NovvyInsertAd 是鼓励的主用法 —— 宿主只有一个播放器,SDK 保证同一时刻只有一条链在驱动它。
把宿主自己知道的事告诉 SDK(三个方法任意线程可调,第三方播放器的回调普遍来自它们自己的原生线程,SDK 内部切主线程。pager 场景下调当前 setActive(true) 的那个实例即可;广播给全部实例同样安全,非活跃实例会自行丢弃):
| 方法 | 什么时候调 | 不接的后果 |
|---|---|---|
notifySeek(fromMs:toMs:) | 宿主播放器发生 seek | SDK 退化为「位置突变」的启发式判断,会被 getter 的量化粒度糊掉边界 |
notifyEnded() | 本集播放完毕 | 强烈建议接:片尾创意(ad_playback_time = -1)的真实信号。兜底要求 durationMs 是真实值,直播 / 尚未 prepare 完返回 0 的播放器会让片尾广告永远不出现,且没有任何报错 |
notifyMediaItemChanged() | 换片源(切集、换清晰度)而没走 setActive(false) | 位置归零会被当成向后 seek |
宿主要求(上一节「宿主要求」表的通用条目仍然适用):
| 要求 | 说明 |
|---|---|
🔒 硬性准入:currentPositionMs / isPlaying / durationMs 必须是廉价的主线程读取 | 临界窗口内 SDK 会以一帧一次的频率读它们。跨线程加锁 / IPC 的实现会拖累主线程:SDK 有降级保护(平均 >2ms 放宽采样并打 warning,平均 >8ms 时会关掉临界窗口采样),但代价是填充率——大部分插入点会被判为迟到丢弃,表现是这个宿主的叙事植入填充率异常偏低 |
🔒 硬性准入:currentPositionMs 的量化粒度 ≤ 100ms | 与上一条正交:那条问 getter 有多贵,这条问它有多粗,SDK 的帧级采样对它完全帮不上忙。⚠️ 最自然的错误实现是「返回上一次播放进度回调里缓存的值」 —— 第三方播放器的进度回调普遍 0.5–1s 一次,即粒度 500–1000ms,十倍于准入线。正确写法是调播放器的同步位置 API(如 TXVodPlayer.currentPlaybackTime),都是廉价的即时读取 |
pause() / play() / seekTo() 无条件执行,包括广告正在显示时 | 宿主 UI 浮在广告之上,进度条常见手势是「按下暂停、松手播放」。那个 play() 不被撤销的话,剧集人声会在广告底下响 —— 画面被盖住,声音是唯一症状 |
playerView 不绘不透明背景 | 宿主的定格帧要透出来,这是「接得像剧情」的前提(「传视频画面实际占据的那一层」仍然适用) |
isPlaying 缓冲时最好报 false | 报 true 不会出错(SDK 用位置停滞兜底),但每次缓冲会多空转最多 250ms。⚠️ 播完那一刻必须转 false,否则没接 notifyEnded() 时片尾创意的兜底会晚一步 |
| 不要 loop 播放 | 播不到结尾,片尾创意永远不触发 |
两条接入路径怎么选
attach(player:playerView:) | attach(adapter:playerView:) | |
|---|---|---|
| 定格精度 | 由构造保证 —— 触发点判定在媒体时钟上(addBoundaryTimeObserver),免疫倍速与主线程卡顿 | SDK 主动采样宿主位置逼近,取决于宿主 getter 的成本与粒度 |
| 宿主要写的代码 | 传两个对象 | 6 个成员 + 建议接 3 个上报 |
拿得到 AVPlayer 的宿主请走默认路径。 adapter 路是「拿不到 player 时的等价路径」,不是首选。另外:attach 时 adapter 已被释放(传了个临时对象)会被忽略并回调 insertAd(_:didFailToLoadWithError:)(.invalidRequest)。
精度:这条路径没有 AVPlayer 的时钟信号,定格精度来自「越接近插播点采样越密」。观察到的跨越晚于插播点超过 100ms 时,SDK 按「宿主 seek 跨过去了」处理并丢弃这支广告。这条路的保证是要么准、要么不出现,不是「永远准」——失败模式是少一次曝光,而不是把创意定格在错误的帧上。
事件回调
一集内多支广告串行,因此 Load/Show/Close 会重复触发。回调统一在主线程触发。
| 回调 | 触发时机 |
|---|---|
insertAdDidLoad(ad) | 一支广告预载完成,等待到点(每支各一次) |
insertAd(ad, didFailToLoadWithError:) | 加载失败(链随即结束,同时回调 insertAdChainDidFinish),error 取值见错误类型 |
insertAdDidShow(ad) | 到点展示、播放器已暂停(不代表曝光已上报,见回调与曝光) |
insertAdDidClose(ad) | 广告关闭、播放器已恢复(剧尾广告关闭后保持暂停) |
insertAdChainDidFinish(ad) | 本集广告全部结束(终态,之后再调 setActive(true) 不会重启) |
切集不产生回调:滑走时不会回调
insertAdDidClose,滑回时也不会再次回调insertAdDidShow—— 恢复的是同一支广告,曝光只上报一次。
insertAd(ad, didFailToLoadWithError:)只在本集为当前活跃集时触发。
完整示例(SwiftUI 竖滑短剧)
三个接入点分别落在页面创建、切集回调、页面消失上:
swift
@MainActor final class EpisodeHost: NovvyInsertAdDelegate {
let player = AVPlayer()
private(set) var insertAd: NovvyInsertAd?
// ① 每集一个实例,绑定即可,不会发请求
func setUp(episode: Int, playerView: UIView) {
player.actionAtItemEnd = .pause // 注意不要循环播放
// …宿主自己的播放器初始化:replaceCurrentItem / AVPlayerLayer 接到 playerView
NovvySDK.shared.setContentContext(NovvyContentContext(seriesName: "剧名"))
let ad = NovvyInsertAd(adUnitId: "your_ad_unit_id", episode: episode)
ad.delegate = self
ad.attach(player: player, playerView: playerView) // playerView = 视频画面实际占据的那一层
insertAd = ad
}
// ③ 页面被回收时释放
func tearDown() {
insertAd?.destroy()
insertAd = nil
}
func insertAdDidLoad(_ ad: NovvyInsertAd) { }
func insertAd(_ ad: NovvyInsertAd, didFailToLoadWithError error: NovvyError) { }
func insertAdDidShow(_ ad: NovvyInsertAd) { /* 可在此禁用自己的拖动/单击暂停 */ }
func insertAdDidClose(_ ad: NovvyInsertAd) { /* 恢复自己的手势 */ }
}
struct EpisodePage: View {
let episode: Int
let isActive: Bool
@State private var host = EpisodeHost()
var body: some View {
PlayerContainer(host: host, episode: episode) // 内部调 host.setUp(episode:playerView:)
.ignoresSafeArea()
// ② 切集:只对新的一集调 setActive(true),旧的会自动停止
.onChange(of: isActive) { _, newValue in
if newValue { host.insertAd?.setActive(true) }
}
.onDisappear { host.tearDown() }
}
}(完整可运行示例见 NovvyAdsDemo/InsertAdTestView.swift。)