做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 = mutableMapOf<String, 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: (Result<AdEntity>) -> 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?adId=A1001", "showUrl": "https://example.com/ad/show?adId=A1001", "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 ?: return@setOnClickListener 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,缓存广告,业务方在合适时机调用show,show的时候从缓存取出广告渲染到一个全屏透明的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:usesCleartextTraffic="true">但这不是长久之计。正规做法是服务端全面切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初始化配置里下发,让服务端能随时控制,而不是写死在客户端里。
拉取流程还要考虑并发去重。如果业务方在同一个时间点对同一个广告位调用了两次load,SDK需要合并为一次请求,不然会浪费流量,也可能造成缓存互相覆盖。
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开始,把请求、缓存、回调、生命周期这条链路跑通,再逐步加插屏、加视频、加预加载。每加一个功能,你会发现对安卓系统组件的理解都会深一层。希望这篇文章和配套源码能帮你少踩几个坑。