ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android广告SDK核心原理:缓存、回调线程与生命周期管理

Android广告SDK核心原理:缓存、回调线程与生命周期管理 做Android开发这几年几乎每个带流量的App都会接广告SDK。接入本身不难一行代码初始化、两行代码拉广告但广告SDK内部到底干了什么、为什么有些App的广告拉得又快又稳有些却要么没填充要么卡死主线程很多同学其实没真正搞明白。这篇文章我把Android广告SDK从拉起、缓存、展示到回调的核心链路完整拆一遍并且附上我整理的一套简化源码可以直接拿来当学习工程或者改造成内部投放测试组件。适合已经会写基础Android代码、想搞懂SDK底层设计、或者准备自研广告模块的开发者。我全程用Kotlin工程没引入重量级框架重点不是让你跑起来而是跟着代码走一遍广告从服务器回到App内部的完整路径理解为什么要做缓存、为什么回调必须切主线程、为什么SDK要自己掌握Activity生命周期。看完你会对接SDK和写SDK都有更深的认识。1. 广告SDK整体架构一个广告请求的完整旅程1.1 用户看到一条广告背后发生了什么先梳理正常链路。用户打开你的App应用启动后SDK先做初始化然后客户端把广告位ID、设备基础信息、网络状态、用户隐私授权状态拼成一个请求体发到广告平台的服务器。服务器根据实时竞价结果返回一条广告内容通常包括素材URL、点击跳转地址、展示上报地址、过期时间。SDK拿到这条广告后不会马上展示而是先缓存到本地等业务方调用展示方法把图片渲染到View上同时向服务器上报展示成功。用户点击广告后SDK拦截点击事件打开落地页或者唤起应用商店把点击行为回传。整个过程其实就是一个标准的请求-加载-展示-上报闭环。但现实中很多问题都出在这条链路的细节上请求超时了怎么处理缓存过期了怎么办广告容器对应的Activity已经退出还展示会不会崩这些都考验SDK设计的健壮性。1.2 为什么必须有SDK这一层而不是App直接请求服务器你可以自己写HTTP请求去拉广告但你会发现很快被现实打脸。广告业务需要处理的事情非常多素材加载要支持多线程并发回调回来后要切换到主线程广告位需要有独立的状态管理页面跳转要处理各种坑——应用内浏览器、深链、应用市场跳转——还要区分展示、点击、关闭等事件并做上报。这些功能如果全塞进业务App里不仅代码量大而且每个接入方都要修一遍同样的Bug毫无意义。所以广告SDK存在的价值就是封装复杂度。对业务方暴露的接口只有几个init、load、show、destroy内部则包含网络请求、缓存、生命周期感知、事件上报、素材渲染、点击处理这套完整的基础设施。这里的核心设计思想是要把不稳定因素隔离在SDK内部业务方只关心回调里的成功和失败。2. 核心机制拆解缓存、回调线程与生命周期感知2.1 初始化为什么必须早、必须全局唯一广告SDK的初始化接口通常要求在Application里调用而且只能调用一次原因有两个。第一SDK内部要注册ActivityLifecycleCallbacks用来感知页面前后台切换这样才能在合适的时机暂停轮播广告、防止广告遮挡页面或者做出内存优化。第二初始化时要拉取服务端的配置信息比如平台开关、超时时间、底价、需要预加载的广告位列表这些配置会影响后续所有请求行为。我在源码里用object单例实现全局入口init方法里记录ApplicationContext和appId注册生命周期监听器。注意这里绝对不能只用Activity的Context初始化否则会造成内存泄漏而且如果Activity销毁了SDK后续没法做全局调度。object AdSDK { private lateinit var appContext: Context private var appId: String private val mainHandler Handler(Looper.getMainLooper()) private val cache AdCache() private var config: SdkConfig SdkConfig() fun init(context: Context, appId: String) { val app context.applicationContext appContext app this.appId appId registerLifecycle(app) loadRemoteConfig() } fun getContext(): Context appContext fun getAppId(): String appId fun getConfig(): SdkConfig config fun postOnMain(runnable: () - Unit) { if (Looper.myLooper() Looper.getMainLooper()) { runnable.invoke() } else { mainHandler.post(runnable) } } }2.2 回调线程模型为什么广告回调必须在主线程广告SDK最容易引发崩溃的原因就是回调线程错乱。网络请求回来的数据天然在子线程JSON解析也可以放在子线程但一旦要刷新UI、弹广告、跳页面就必须回到主线程。如果SDK在子线程调用回调而业务方直接在该回调里操作View就会抛Subscriber already subscribed这类异常严重时直接崩溃闪退。我的做法是把回调统一放到主线程派发。所有从网络层回来的结果先丢给一个分发器由分发器判断当前线程如果不在主线程就通过Handler切换。这个设计从SDK第一版就要定下来不然后期到处加runOnUiThread会非常痛苦。interface AdCallback { fun onAdLoaded(ad: AdEntity) fun onAdFailed(code: Int, message: String) fun onAdShown() fun onAdClicked() }加载方法大概是这样的fun load(adUnitId: String, callback: AdCallback) { AdRequester.fetch(adUnitId) { result - AdSDK.postOnMain { when (result) { is Result.Success - { cache.put(adUnitId, result.ad) callback.onAdLoaded(result.ad) } is Result.Failure - callback.onAdFailed(result.code, result.message) } } } }2.3 缓存策略预加载比实时加载可靠得多广告请求是网络操作耗时少则两百毫秒多则一两秒。如果用户每次进入广告场景才发起请求体验会非常差而且容易在弱网环境下直接拉不到素材。行业内通用的做法是预加载提前把广告拉到本地缓存等真正要展示的时候直接从缓存取取不到再走实时加载。缓存需要考虑两个问题过期时间和单广告位缓存上限。广告平台返回的广告通常有时效性超过规定时间展示会影响计费和结算所以缓存类里存了expireTime读取时先判断是不是有效。每个广告位一般只保留一条广告缓存避免缓存太多占内存因为广告素材本来就是为了展示而存在的不需要像图片库那样做多级缓存。class AdCache { data class CacheItem( val ad: AdEntity, val expireAt: Long ) private val map mutableMapOfString, CacheItem() private val lock Any() fun put(adUnitId: String, ad: AdEntity) { synchronized(lock) { map[adUnitId] CacheItem(ad, System.currentTimeMillis() ad.expireSeconds * 1000) } } fun get(adUnitId: String): AdEntity? { synchronized(lock) { val item map[adUnitId] ?: return null if (item.expireAt System.currentTimeMillis()) { map.remove(adUnitId) return null } return item.ad } } fun remove(adUnitId: String) { synchronized(lock) { map.remove(adUnitId) } } }2.4 展示上报与点击跳转的处理思路广告展示之后SDK需要向服务端上报展示事件这是平台结算的依据。上报一般走独立的轻量接口图片URL可以放在不可见的View里加载通知型上报可以提前缓存。点击跳转则复杂一点优先尝试通过Intent打开深链如果失败则加载落地页落地页如果没有配置就跳应用市场。这里需要注意跳转必须放在主线程并且要判断目标Activity是否存在防止ActivityNotFound异常。我在源码里用ClickDispatcher处理点击跳转逻辑当用户点击Banner时先回调onAdClicked通知业务方再执行跳转。跳转前记得记录当前时间戳用来过滤用户在一两秒内的重复点击。很多平台会做点击频控客户端不做限制的话不仅容易被判定作弊还会白白消耗App的跳转流量。3. 手写一个最小可用的广告SDK附源码3.1 工程目录设计整个工程按单一模块设计主要分成四个包com.pxdemo.adsdk ├── core # SDK入口、配置、生命周期注册 ├── network # 请求、响应解析 ├── cache # 广告缓存 ├── adtype # Banner广告View、插屏控制器 └── callback # 广告回调接口这种分层思路是以功能而不是以业务维度的SDK本身不关心业务方接的是Banner还是插屏只关心能不能拉到广告、能不能展示、事件能不能如实上报。所以adtype包下面每个广告类型只做一件事——把AdEntity渲染出来并且把点击、展示事件抛给回调。3.2 网络请求与解析实现为了减少依赖这里用HttpURLConnection实现请求数据格式是JSON。实际生产环境中你可以替换成OkHttp但核心流程是一样的拼接URL和参数、发起请求、拿到响应状态码、读取body、解析JSON、封装成结果对象。请求参数至少要包含appId、adUnitId、设备型号、系统版本、网络类型和当前时间戳。这些参数中完整的合规参数需要根据平台要求增减但这里有一个很重要的原则SDK收集的信息必须最小化只收集完成任务所必需的信息并且在初始化时通过回调告知App开发者。object AdRequester { private const val AD_SERVER https://your-ad-server.example.com/api/ad fun fetch(adUnitId: String, callback: (ResultAdEntity) - Unit) { Thread { var connection: HttpURLConnection? null try { val url URL($AD_SERVER?appId${AdSDK.getAppId()}adUnitId$adUnitId) connection (url.openConnection() as HttpURLConnection).apply { requestMethod GET connectTimeout 5000 readTimeout 5000 } val code connection.responseCode if (code 200) { val body connection.inputStream.bufferedReader().use { it.readText() } val ad parseAd(body) callback(Result.Success(ad)) } else { callback(Result.Failure(code, server error $code)) } } catch (e: Exception) { callback(Result.Failure(-1, e.message ?: network error)) } finally { connection?.disconnect() } }.start() } private fun parseAd(json: String): AdEntity { val obj JSONObject(json) val data obj.getJSONObject(data) return AdEntity( adId data.getString(adId), adType data.getString(adType), imageUrl data.getString(imageUrl), clickUrl data.getString(clickUrl), showUrl data.getString(showUrl), expireSeconds data.getLong(expireSeconds) ) } }模拟服务端返回的JSON结构{ code: 0, msg: success, data: { adId: A1001, adType: banner, imageUrl: https://cdn.example.com/ad/banner_01.png, clickUrl: https://example.com/ad/click?adIdA1001, showUrl: https://example.com/ad/show?adIdA1001, expireSeconds: 3600 } }AdEntity就是广告的实体模型data class AdEntity( val adId: String, val adType: String, val imageUrl: String, val clickUrl: String, val showUrl: String, val expireSeconds: Long )这里的Result是一个密封类Success持有广告实体Failure持有错误码和错误信息。用密封类的好处是when分支可以做穷举编译器会帮你检查漏掉的支这在SDK回调场景里很实用。3.3 Banner广告View的实现Banner广告是最基础的广告形态我用FrameLayout包装一个ImageView来实现。对外提供bindData方法内部负责图片下载和点击事件处理。图片下载直接放在子线程下载完成后切主线程设置图片同时启动展示上报。class BannerAdView JvmOverloads constructor( context: Context, attrs: AttributeSet? null ) : FrameLayout(context, attrs) { private val imageView ImageView(context).apply { scaleType ImageView.ScaleType.FIT_XY } private var adEntity: AdEntity? null private var callback: AdCallback? null init { addView(imageView, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) setOnClickListener { val ad adEntity ?: returnsetOnClickListener callback?.onAdClicked() ClickDispatcher.open(ad.clickUrl) } } fun bindAd(ad: AdEntity, callback: AdCallback?) { this.adEntity ad this.callback callback loadImage(ad.imageUrl) reportShow(ad.showUrl) } private fun loadImage(url: String) { Thread { try { val bitmap BitmapFactory.decodeStream(URL(url).openStream()) AdSDK.postOnMain { imageView.setImageBitmap(bitmap) callback?.onAdShown() } } catch (e: Exception) { AdSDK.postOnMain { callback?.onAdFailed(-2, image load failed) } } }.start() } private fun reportShow(url: String) { Thread { try { val connection URL(url).openConnection() as HttpURLConnection connection.requestMethod GET connection.connectTimeout 3000 connection.readTimeout 3000 connection.responseCode connection.disconnect() } catch (_: Exception) { } }.start() } }这里有个细节Banner的宽高比例应该由SDK内部根据素材尺寸计算不能直接拉满整个屏幕。我在实际项目中见过不少接入方把Banner控件宽高设置为match_parent出来的效果要么遮挡内容要么比例失调。更稳妥的做法是服务端下发广告的期望宽高比客户端按比例动态调整控件高度。3.4 抽插屏广告的控制逻辑插屏和Banner不一样它不是常驻View而是等到合适的时机主动弹出。所以逻辑上需要做一个控制器先load缓存广告业务方在合适时机调用showshow的时候从缓存取出广告渲染到一个全屏透明的Activity或者Dialog里。为了保持源码示例简单我用全屏Dialog实现。但生产环境里插屏广告建议使用独立的透明Activity承载因为Dialog在某些定制ROM上可能会出现窗口类型层级问题而且Activity的方式更容易处理冷启动跳转和返回键事件。class InterstitialAd(private val adUnitId: String) { fun load(callback: AdCallback) { AdRequester.fetch(adUnitId) { result - AdSDK.postOnMain { when (result) { is Result.Success - callback.onAdLoaded(result.ad) is Result.Failure - callback.onAdFailed(result.code, result.message) } } } } fun show(activity: Activity, callback: AdCallback?) { val ad AdSDK.cache.get(adUnitId) ?: return val dialog Dialog(activity) val view BannerAdView(activity) view.bindAd(ad, callback) dialog.setContentView(view) dialog.show() } }这里的show方法先取缓存如果缓存为空就静默返回不做任何提示。这是广告场景和普通业务场景的一大区别广告没加载成功用户不该被打扰也不应该有任何弹窗报错。错误信息只应该通过回调给到业务方用于统计和排查。4. 广告SDK接入与调试常见问题我踩过的坑4.1 Android 9及以上明文流量限制很多Android开发者第一次接广告SDK或者自建广告服务时会遇到一个诡异的问题自己的手机真机调试一切正常但测试同事的手机一请求就失败。排查半天才发现是Android 9开始默认禁止明文HTTP流量如果广告服务端没有配置HTTPS或者客户端没有显式允许明文流量请求在底层直接就被拦掉了。临时处理办法是在AndroidManifest的application节点下加一行application android:usesCleartextTraffictrue但这不是长久之计。正规做法是服务端全面切HTTPS开发测试环境单独配置networkSecurityConfig。因为如果App本身是上架产品usesCleartextTraffic全开会有合规风险也会被应用市场审核系统标记。4.2 生命周期没有绑定后台展示广告导致状态错乱广告SDK的生命周期问题是最常被忽略的部分。比如用户点开广告落地页切到后台又切回来整个过程中Activity已经执行了onPause和onResume。如果SDK没有感知到这些状态视频广告可能在前台继续播放Banner轮播可能会在后台不断刷新图片白白消耗流量。解决办法是注册ActivityLifecycleCallbacks。Application从API 14开始就提供这个接口不需要额外依赖。在onActivityPaused里暂停轮播和视频在onActivityResumed里恢复播放在onActivityDestroyed里清理对Activity的强引用。我的源码里已经包含注册代码具体回调方法可以根据自己的广告类型扩展。4.3 混淆规则不完善SDK崩溃在Release包SDK里的数据类、回调接口、JSON解析相关类如果被混淆往往会出现两种情况一种是Gson或者自研JSON解析器反射不到字段名导致解析出来的对象全是默认值另一种是回调方法签名被混淆后调用方和SDK的接口对不上运行时报NoSuchMethodError。Release打包前必须在混淆规则里把SDK的对外接口、实体类、回调方法保留-keep class com.pxdemo.adsdk.** { *; } -keep interface com.pxdemo.adsdk.** { *; }这段规则放在接入方的proguard-rules.pro文件里即可。如果你把SDK做成aar发布建议在SDK自身的consumer-rules.pro里声明这些keep规则这样接入方不需要知道内部细节依赖时自动生效。4.4 广告落地页跳转被系统拦截点击广告后跳转落地页在部分机型上经常出现没有任何反应的情况。排查时先从日志看有没有ActivityNotFoundException如果有说明落地页的Activity没有在SDK的Manifest里声明或者声明的主题导致不能在外部的Application启动。这里有一个经验点击跳转这个动作尽量使用ContextCompat.startActivity并且给Intent加上FLAG_ACTIVITY_NEW_TASK因为SDK里拿到的Context有可能是非Activity类型的。如果跳转的是深链还要确认Scheme和应用市场路由表的匹配。部分状态下市面上常见的“拉起其他App”逻辑会触发系统询问弹窗这类交互在测试环境很常见不能当成Bug要区分清楚场景。4.5 一套排查方法日志、抓包、线上监控广告SDK排查问题最忌讳的是直接看业务层代码。我个人的排查顺序是先看SDK日志开关是否打开确认初始化成功再确认请求是否发出、返回什么状态码然后看加载的回调是否走到最后才看展示和点击环节。为了支持这个顺序正式发布的SDK也要留一个Debug开关线上环境可以通过配置远程打开这样定位问题不用每次出包。抓包推荐直接用Android Studio自带的Network Inspector它会拦截App的所有网络请求看请求参数和响应体都很方便。如果SDK内的请求走了非标准协议那就只能靠日志打点或者在代理层做记录。5. 从原理到实战的几个进阶设计建议5.1 数据兜底如何让广告填充率更高移动广告有个非常现实的问题广告位不是随时都有广告可投。服务端没有匹配到广告时客户端再怎么努力也没用但客户端可以决定的是拉取失败之后要不要兜底。比较成熟的做法是设置失败重试策略比如第一次失败后延迟5秒重试一次再失败后延迟30秒重试一次最多重试3次。注意这里的重试次数和间隔要在SDK初始化配置里下发让服务端能随时控制而不是写死在客户端里。拉取流程还要考虑并发去重。如果业务方在同一个时间点对同一个广告位调用了两次loadSDK需要合并为一次请求不然会浪费流量也可能造成缓存互相覆盖。5.2 点击防作弊基础校验必须有客户端点击防作弊是广告平台重点关注的问题。SDK至少要做的有这几点记录每次点击的时间间隔小于设定阈值的点击直接过滤记录点击时是否处于前台通过MotionEvent的坐标范围判断点击是否落在广告区域内。这些逻辑不复杂但如果没有做大量异常点击会导致账户被平台判定作弊严重的直接封禁AppID。我写过一个比较简单的校验器逻辑是首次点击记录时间戳后续点击时间差小于800毫秒直接丢弃然后记录整个生命周期内的点击次数超过每分钟10次就触发频控。这套规则线上验证下来误杀率很低。其实防作弊是一个动态博弈过程客户端的校验只能挡掉一部分机器行为真正的风控在服务端。5.3 权限和隐私设计要克制广告SDK在设计权限时应该遵循一个原则能不申请的权限不申请能不从App侧收集的信息不收集。很多开发者为了提升广告填充率上来就要定位权限、读设备标识、读应用列表结果应用市场上架审查直接被拒。合理的做法是只依赖App已经正常声明的权限做功能判断不该自己主动索要权限。比如网络状态权限SDK可以通过ConnectivityManager获取网络类型这个不需要额外权限。设备信息如果业务上确实用得到也要在初始化时通过开关让App开发者自行决定是否上报不能闷头打包就传。这个点在真实项目中往往是甲方和平台之间的博弈点但从SDK开发者的角度把决定权交给业务方是最稳妥的。5.4 架构层面的可扩展性源码里我把展示层和请求层完全分离这样如果日后要接入新的广告形态比如原生模板、视频流、开屏广告只需要在adtype包下新增一个类复用AdRequester和AdCache就可以。缓存策略也是按广告位ID为key不关心广告类型的差异。如果你要继续扩展我建议下一步可以这样做把图片下载抽成一个ImageLoader支持LRU缓存再加上Ads的预加载列表在初始化的时候自动拉取多个广告位的广告再往上是事件埋点池所有展示、点击、失败事件都先写入内存队列再批量上报。这样一套东西做完你的SDK基本就是一个主流商业SDK的缩减版了。最后分享一点我自己的体会做这套简化SDK最大的收获是我真正理解了广告SDK和普通业务代码的一个核心区别普通业务代码的目标是让功能在页面上呈现广告SDK的目标是在不可控的网络条件、复杂的机型环境、频繁变动的页面生命周期里尽可能稳定地完成一次广告事件闭环。所谓稳定不是不报错而是知道什么时候该重试、什么时候该静默、什么时候该把错误抛给业务方。如果你也想自己做一套广告模块建议从Banner开始把请求、缓存、回调、生命周期这条链路跑通再逐步加插屏、加视频、加预加载。每加一个功能你会发现对安卓系统组件的理解都会深一层。希望这篇文章和配套源码能帮你少踩几个坑。
返回列表