Android广告SDK架构详解:从请求链路到源码实现与性能优化
1. 广告SDK整体架构一条广告从请求到展示的完整链路做Android开发这些年我接过的广告SDK少说也有十几个从早期的经典SDK到现在的聚合SDK大多逃不过一套固定的底层逻辑。很多人在集成广告SDK时只关心怎么调接口、怎么监听回调一旦遇到加载失败、曝光不准、崩溃频发的问题就抓瞎。原因很简单你没理解广告SDK的内部结构。我最初入行时也被广告SDK的黑盒折磨过加载不出来怪网络、曝光量对不上怪前端、内存暴涨怪机型后来花了一整个版本迭代的时间去读核心源码、抓全链路日志才把这套东西彻底摸透。这篇文章我会直接把广告SDK的核心原理拆开讲清楚从中我们可以看到它的整体架构是怎么搭建的、数据是怎么流转的、生命周期是怎么维护的并给出可参考的源码骨架。先把一条广告的完整生命周期摆出来初始化→请求→竞价返回→缓存→展示→曝光上报→点击上报→关闭销毁。任何一个环节设计不到位都会直接影响收益和体验。1.1 SDK的模块划分为什么是三段式架构而不是一个大杂烩正规的商业广告SDK内部模块划分通常非常清晰大致可以分成三层层次模块名职责关键类示例接口层ApiManager暴露给开发者的全部入口AdManager、BannerAd、InterstitialAd核心层CoreEngine请求管道、策略中心、缓存池、事件分发AdRequester、AdRouter、CachePool基础层BaseLayer网络、图片加载、存储、设备信息采集、埋点HttpClient、ImageLoader、DeviceHelper三层架构的最大价值在于解耦与可替换。基础层的网络模块可以无缝从OKHttp换成自研连接池核心层的策略中心可以针对不同广告位动态下发不同的请求参数接口层面向开发者保持不变。我见过不少自研广告SDK的项目喜欢把所有逻辑堆在十几个类里后期加一个广告样式就要改几十处这就是没有吃透分层设计的结果。1.2 一个App启动后SDK做了什么初始化时序拆解先看初始化阶段。开发者一般会在Application里调用AdManager.init(context, appId)这一步SDK内部做的事情远比大多数人想的多public class AdManager { private static volatile AdManager instance; private InitConfig config; private Context appContext; private HandlerThread coreThread; private CoreHandler coreHandler; private AdManager() {} public static AdManager getInstance() { if (instance null) { synchronized (AdManager.class) { if (instance null) { instance new AdManager(); } } } return instance; } public void init(Context context, InitConfig config) { this.appContext context.getApplicationContext(); this.config config; // 1. 启动独立的核心线程避免广告逻辑卡主线程 coreThread new HandlerThread(ad-core-thread); coreThread.start(); coreHandler new CoreHandler(coreThread.getLooper()); // 2. 初始化网络模块、图片加载器、缓存目录 HttpClient.install(appContext); ImageLoader.install(appContext); CachePool.initialize(appContext, config.getCacheDir()); // 3. 异步拉取服务器全局配置比如超时时间、竞价超时、开关策略 coreHandler.post(() - ConfigCenter.sync(config)); } }这里有一个非常关键的细节SDK的初始化绝不可以在主线程做耗时操作。抖音、快手这类渠道SDK的初始化方法里会起独立的HandlerThread把网络请求、配置解析、目录创建全部丢到子线程这样即使SDK初始化失败也不会卡住App启动。2. 广告请求全流程从loadAd到广告返回中间发生了什么很多开发者对广告请求的理解停留在发一个网络请求等服务器返回JSON。如果真的只有这么简单那接入广告SDK的成本就不会比写一个普通的网络请求高多少。真实情况是一次加载请求可能同时打给多个渠道还要经过策略中心的调度和优先级排序甚至要从本地缓存里先捞广告展示。2.1 请求参数的构造看似简单实则充满细节以横幅广告加载为例BannerAd.loadAd(adUnitId, width, height)内部会构造一个AdRequest对象public class AdRequester { public void loadAd(AdSlot slot, AdLoadListener listener) { AdRequest request new AdRequest.Builder() .setAppId(config.getAppId()) .setAdUnitId(slot.getAdUnitId()) .setAdType(slot.getAdType()) // 横幅/插屏/开屏/激励视频 .setSize(slot.getWidth(), slot.getHeight()) .setOrientation(DeviceHelper.getOrientation()) .setOsVersion(Build.VERSION.RELEASE) .setModel(Build.MODEL) .setAndroidId(DeviceHelper.getAndroidId()) .setOaid(DeviceHelper.getOAID()) .setNetworkType(NetworkHelper.getNetworkType()) .setCarrier(NetworkHelper.getCarrier()) .setTimestamp(System.currentTimeMillis()) .build(); // 后续逻辑... } }为什么需要OAID而不是IMEI因为从Android 10开始IMEI的获取受到严格限制非系统应用基本拿不到。广告标识的采集是合规底线凡是适配到Android 10以上却仍然依赖IMEI的SDK要么是内部做了妥协处理要么迟早崩溃。请求参数的核心逻辑还在于分批上抛。设备信息、用户画像是一批广告位尺寸、页面上下文是另一批这样设计是为了服务器端可以统一解析并缓存。如果你自研SDK我建议参数统一用Protocol Buffer序列化而不是JSON——广告请求的战报量级是日均千万甚至上亿JSON解析的CPU开销和包体体积都是不可忽视的成本。2.2 缓存策略先查缓存再走网络广告和普通接口最大的区别在于广告是预置资源不希望用户每次刷到都要等网络。所以成熟的SDK内部必然维护一个缓存池。public class CachePool { private final LruCacheString, AdWrapper memoryCache; private final DiskLruCache diskCache; private final MapString, ListAdWrapper slotAdsMap; public synchronized boolean putAd(AdUnit adUnit, AdWrapper ad) { // 每个广告位最多缓存N条广告 ListAdWrapper list slotAdsMap.getOrDefault(adUnit.getId(), new ArrayList()); if (list.size() adUnit.getMaxCacheCount()) { list.remove(0); } list.add(ad); memoryCache.put(adUnit.getId() _ ad.getRequestId(), ad); return true; } public synchronized AdWrapper getAd(String adUnitId) { ListAdWrapper list slotAdsMap.get(adUnitId); if (list null || list.isEmpty()) return null; // 优先返回未过期且未展示过的广告 for (AdWrapper ad : list) { if (!ad.isExpired() !ad.isShown()) { return ad; } } return null; } }缓存池设计的两个痛点过期时间怎么定横幅和插屏广告的有效期通常在30分钟到4小时之间过期广告如果继续渲染会出现素材已下架但还能看到的问题用户体验极差。有效期太短又导致缓存命中率低加载速度上不去。我习惯的做法是服务器在下发物料时会返回expireTimeSDK用这个时间做绝对过期判断同时本地兜底一个最长期限比如24小时防止服务器漏传字段导致缓存脏数据长期驻留。展示前校验广告从缓存池取出来之后不能直接渲染。要校验App是否在前台、页面是否已销毁、广告位是否还处于活跃状态。这个校验逻辑在AdRouter里统一处理避免每个广告位自己写判断导致逻辑分散。2.3 失败重试与超时控制为什么不能无限重试广告请求涉及竞价超时控制极其关键。一次广告请求从发出到返回经过SDK打包参数、渠道网关转发、服务器决策、物料拼接、回包解析任何一个环节抖动都会导致超时。商业SDK的请求管线里会设置两级超时第一级是总超时比如开屏广告3秒、激励视频5秒超过这个时间直接回调失败不能让用户干等第二级是单次渠道超时聚合SDK并发请求多个渠道时单个渠道如果1.5秒内没有返回就当作失败不阻塞整体流程。重试的坑我也踩过不少。早期我设计的重试策略很简单粗暴失败就重试3次结果广告加载失败率不降反升因为短时间内的并发重试会导致服务器压力飙升而且重试通常也是同样超时。正确做法是指数退避第一次失败等2秒第二次失败等4秒第三次失败等8秒并且增加随机抖动避免多个客户端同时重试形成惊群效应。public class RetryPolicy { public static long getDelayMillis(int retryCount) { long base 1000L * (long) Math.pow(2, retryCount); long randomJitter new Random().nextInt(500); return Math.min(base randomJitter, 10_000L); } }3. 广告生命周期管理从创建View到销毁系统如何保证不泄漏广告SDK里最容易出问题的地方不在业务逻辑而在生命周期管理。一个广告从load到show到close涉及Activity的onResume/onPause/onDestroy涉及视图的添加与移除涉及Handler的消息滞留任何一个环节处理不当就是内存泄漏。3.1 渲染视图的绑定与解绑拿横幅广告来说核心是AdContainerView的绑定过程public class BannerAdView extends FrameLayout { private AdWrapper adWrapper; private SimpleAdRenderer renderer; private boolean attached false; public void renderAd(AdWrapper ad) { this.adWrapper ad; renderer new SimpleAdRenderer(getContext()); renderer.setAdData(ad.getMaterial()); removeAllViews(); addView(renderer, new LayoutParams( LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)); attached true; // 触发曝光上报 TrackManager.get().reportExposure(ad); } Override protected void onDetachedFromWindow() { super.onDetachedFromWindow(); // View被移除时必须解绑渲染器阻断渲染器的Handler消息 if (renderer ! null) { renderer.destroy(); renderer null; } attached false; } }很多开发者在自定义View时忽略onDetachedFromWindow导致广告展示结束后只是被removeView但内部的图片加载回调、点击监测任务还在跑长时间持有Activity引用最终触发OOM。广告View的释放必须在Window解绑的同时进行不是等GarbageCollector来回收。3.2 状态机与回调线程切换广告对象本身是一个状态机。加载中、加载成功、加载失败、展示中、点击中、关闭每个状态之间的流转必须有严格的触发条件。public class AdLifecycle { public enum State { IDLE, LOADING, LOADED, SHOWING, CLICKED, CLOSED, ERROR } private State state State.IDLE; public synchronized boolean load() { if (state ! State.IDLE) { throw new IllegalStateException(Cannot load while state is state); } state State.LOADING; return true; } public synchronized boolean show() { if (state ! State.LOADED) { // 未加载完成不能展示 return false; } state State.SHOWING; return true; } }回调线程的切换也是广告SDK老生常谈的问题。网络请求是异步的但回调给开发者时必须保证在主线程执行否则开发者会在子线程里更新UI直接崩溃。这个切换在SDK内部通过一个MainHandler完成public class AdCallbackDispatcher { private static final Handler MAIN_HANDLER new Handler(Looper.getMainLooper()); public static void postOnMainThread(Runnable runnable) { if (Looper.myLooper() Looper.getMainLooper()) { runnable.run(); } else { MAIN_HANDLER.post(runnable); } } }4. 源码精读核心模块的骨架设计标题说了附源码光讲原理不贴代码肯定不够。下面我按模块拆解一套可运行的广告SDK核心源码骨架。这里我给出的是我实际项目里演进过的版本去掉了业务敏感的配置只剩骨架。4.1 广告加载器异步管线的组装广告加载器是整个SDK的发动机。它把请求发起、缓存查询、策略调度、回调派发组装成一条完整的流水线。public class AdLoader { private static final String TAG AdLoader; private final ExecutorService requestExecutor Executors.newFixedThreadPool(2); private final MapString, ListAdLoadListener listenerMap new ConcurrentHashMap(); public void loadAd(AdSlot slot, AdLoadListener listener) { String unitId slot.getAdUnitId(); // 1. 防重复加载同一个广告位同时只允许一个loading任务 synchronized (listenerMap) { if (listenerMap.containsKey(unitId) !listenerMap.get(unitId).isEmpty()) { listener.onAdError(new AdError(ErrorCode.LOADING_EXISTS)); return; } } registerListener(unitId, listener); // 2. 先查缓存 AdWrapper cached CachePool.get().getAd(unitId); if (cached ! null) { AdCallbackDispatcher.postOnMainThread(() - { listener.onAdLoaded(cached); unregisterListener(unitId, listener); }); return; } // 3. 缓存未命中走网络请求 requestExecutor.execute(() - { try { AdRequester requester new AdRequester(); AdResponse response requester.request(slot); if (response.isSuccess()) { AdWrapper ad new AdWrapper(response); CachePool.get().putAd(slot, ad); AdCallbackDispatcher.postOnMainThread(() - { listener.onAdLoaded(ad); unregisterListener(unitId, listener); }); } else { AdCallbackDispatcher.postOnMainThread(() - { listener.onAdError(new AdError(response.getErrorCode())); unregisterListener(unitId, listener); }); } } catch (Exception e) { Log.e(TAG, load ad failed, e); AdCallbackDispatcher.postOnMainThread(() - { listener.onAdError(new AdError(ErrorCode.UNKNOWN)); unregisterListener(unitId, listener); }); } }); } }这套代码在业务里已经很能打了但仍然有改进空间。比如多广告位并发加载时每个广告位会各自占用一个线程如果线程池只有2个线程连续加载3个不同广告位的广告第三个会被阻塞。改进方案是用ThreadPoolExecutor的动态线程数或者干脆给每个广告位分配独立的关键任务队列。关于缓存的一个改进思路如果缓存命中就直接回调不走网络请求看起来没问题但忽略了展示广告的时效性要求。有些运营场景希望每次展示的都是最新素材所以成熟的SDK会让缓存设一个refreshInterval比如首次展示用旧素材兜底同时后台静默拉新素材第二次展示时优先用新素材。4.2 渲染器如何用一套代码兼容Banner、插屏、开屏、激励视频广告的展示形态五花八门但渲染逻辑的内核是一致的拿到物料按类型渲染成不同的View。这里用适配器模式最合适。public interface AdRenderer { View createView(Context context); void destroy(); void onExposure(); void onClick(); } public class BannerRenderer implements AdRenderer { private final MaterialData material; public BannerRenderer(MaterialData material) { this.material material; } Override public View createView(Context context) { LinearLayout container new LinearLayout(context); container.setOrientation(LinearLayout.VERTICAL); // 标题 TextView title new TextView(context); title.setText(material.getTitle()); title.setTextSize(16); title.setMaxLines(1); container.addView(title); // 主图 ImageView imageView new ImageView(context); ImageLoader.get().load(material.getImageUrl(), imageView); container.addView(imageView, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, dp2px(context, 250))); return container; } Override public void destroy() { // 释放图片缓存引用取消网络加载 } } public class RendererFactory { public static AdRenderer create(AdType type, MaterialData material) { switch (type) { case BANNER: return new BannerRenderer(material); case INTERSTITIAL: return new InterstitialRenderer(material); case SPLASH: return new SplashRenderer(material); default: throw new IllegalArgumentException(unknown type); } } }有人说适配器老套但广告SDK的场景里它真的是最稳的。因为以后你接新的广告样式只需要新写一个Renderer不需要改动加载器和请求器。这里有一个小坑要提醒ImageView的图片加载回调时机。如果图片还没加载完成用户就已经把广告View销毁了onResponse回调里如果再给ImageView设置Bitmap会触发View的重新绘制而View已经detach会导致崩溃。所以ImageLoader内部要做弱引用包装public class ImageLoader { public static void load(String url, ImageView iv) { RequestOptions options new RequestOptions() .diskCacheStrategy(DiskCacheStrategy.RESOURCE); Glide.with(iv.getContext()) .load(url) .apply(options) .into(iv); } }4.3 事件上报曝光和点击为什么不能靠服务端统计广告行业的计费依据是曝光和点击这两个数据必须准确。大部分广告SDK采用端上上报 服务端核验的双重机制。端上上报的关键是时机。曝光上报不能放在init里也不能放在loadAd回调里而必须放在广告View真正被绘制到窗口之后。实现方式就是在View的onAttachedToWindow和onWindowVisibilityChanged里判断Override protected void onAttachedToWindow() { super.onAttachedToWindow(); if (adWrapper ! null !adWrapper.isReported()) { if (isShown()) { reportExposure(); } } } Override protected void onWindowVisibilityChanged(int visibility) { super.onWindowVisibilityChanged(visibility); if (visibility VISIBLE adWrapper ! null !adWrapper.isReported()) { reportExposure(); } }为什么要强调这个因为我见过太多App在Activity的onResume里就上报曝光结果广告View还没绘制完成导致曝光量虚高但实际根本没被用户看到。广告平台也不是傻子端上上报的曝光量如果远大于服务端监测的实际曝光会被判定作弊轻则警告重则封禁。曝光上报必须与真实可见性绑定。上报的实体内容一般包括public class TrackRequest { private final String requestId; private final String adUnitId; private final String eventType; // IMPRESSION / CLICK / CLOSE private final long timestamp; private final String networkType; private final String deviceModel; } public class TrackManager { private final ListTrackRequest pendingEvents new ArrayList(); public void report(String eventType, AdWrapper ad) { TrackRequest event new TrackRequest.TrackRequestBuilder() .requestId(ad.getRequestId()) .adUnitId(ad.getSlot().getAdUnitId()) .eventType(eventType) .timestamp(System.currentTimeMillis()) .build(); // 先入本地队列批量上报失败落盘 pendingEvents.add(event); flush(); } private void flush() { if (pendingEvents.size() 10) return; // 批量上报 ListTrackRequest batch new ArrayList(pendingEvents); pendingEvents.clear(); doReport(batch); } }批量上报可以大幅降低网络开销但要注意失败后的补偿策略。我的方案是把待上报数据先存在一个SQLite表里上报成功后再删除这样即使App被杀死下次启动也能继续补报。5. 广告SDK接入的常见崩溃与性能优化实录这部分我专门整理一套我在多年实战中反复遇到的坑全是真实场景不是背书。5.1 开屏广告点击后跳转返回时Activity重叠开屏广告的场景尤其复杂。用户点击广告跳转到落地页广告SDK内部需要新建一个AdLandingActivity或者利用Chrome Custom Tabs由于需要跨App通信跳转和返回的时序很难控制。最典型的问题是开屏Activity已经销毁但广告落地页返回时还试图在已经finish的Activity上弹Dialog。这个问题在解码Layer里是个隐藏逻辑广告SDK在创建一个PopupDialog或者GuideView时经常拿的是当时缓存的Activity引用而不是最新的ResumedActivity。手动维护一个前百Activity栈是治标不治本我用过一个相对优雅的方案在AdManager里注册Application.ActivityLifecycleCallbacks拿到当前resumed的Activity所有弹窗、跳转、回调一律走这个最新的Activity。public class ActivityStackManager implements Application.ActivityLifecycleCallbacks { private final AtomicReferenceActivity currentActivity new AtomicReference(); Override public void onActivityResumed(Activity activity) { currentActivity.set(activity); } Override public void onActivityPaused(Activity activity) { if (currentActivity.get() activity) { currentActivity.set(null); } } public Activity getTopActivity() { Activity activity currentActivity.get(); if (activity null || activity.isFinishing() || activity.isDestroyed()) { return null; } return activity; } }5.2 Handler消息滞留导致的内存泄漏广告SDK里大量使用Handler做延时任务比如倒计时关闭按钮、加载超时、插屏展示几秒后自动关闭等。如果Activity退出时Handler队列里还挂着延时消息Activity就永远无法被回收。标准的解法是在Activity退出和广告销毁时清空消息队列public class CountingHandler extends Handler { public CountingHandler(Looper looper) { super(looper); } public void clearAllMessages() { removeCallbacksAndMessages(null); } }清空消息队列这个动作一定要和AdRenderer.destroy()的调用时机放在一起并且在onDetachedFromWindow再次兜底执行。双保险比只在一处调用的容错率高得多。5.3 混淆规则为什么广告不展示加了几条keep规则就好了广告SDK集成到正式项目时因为App开启混淆后反射和网络序列化字段都被改了导致服务器返回的JSON解析不出来广告直接加载失败。所以在proguard文件中必须保留SDK的实体类。-keep class com.example.adsdk.** { *; } -keep class com.example.adsdk.core.model.** { *; } -keepclassmembers class * { com.google.gson.annotations.SerializedName fields; } -keepclassmembers enum com.example.adsdk.** { *; }这行SerializedName规则特别容易漏。我遇到过不止一次漏掉这条keep规则后所有字段名被混淆成a、b、cGson按注解名找不到原始字段整套数据模型全部为空表现就是广告加载回调成功了但View上什么都没有。5.4 线程调度主线程卡顿的隐形杀手广告SDK里有个容易被忽视的点图片加载和素材解析的线程调度。如果SDK把一个很大的GIF或视频首帧丢到主线程去做解码那闪退和卡顿是必然的。一个合格的图片加载流程应该是从网络或磁盘读字节流 → 工作线程解析图片宽高、格式 → 工作线程根据目标View尺寸做压缩和缩放 → 工作线程只在最后一步把处理好的Bitmap丢回主线程设置到ImageView上。核心就是在分发回调之前做一次解码。Android的BitmapFactory提供了inSampleSize选项可以在加载时就按比例采样避免整张大图进内存。public class BitmapDecodeUtil { public static Bitmap decodeSampledBitmap(File file, int reqWidth, int reqHeight) { BitmapFactory.Options options new BitmapFactory.Options(); options.inJustDecodeBounds true; BitmapFactory.decodeFile(file.getAbsolutePath(), options); options.inSampleSize calculateInSampleSize(options, reqWidth, reqHeight); options.inJustDecodeBounds false; return BitmapFactory.decodeFile(file.getAbsolutePath(), options); } private static int calculateInSampleSize( BitmapFactory.Options options, int reqWidth, int reqHeight) { final int height options.outHeight; final int width options.outWidth; int inSampleSize 1; if (height reqHeight || width reqWidth) { final int halfHeight height / 2; final int halfWidth width / 2; while ((halfHeight / inSampleSize) reqHeight (halfWidth / inSampleSize) reqWidth) { inSampleSize * 2; } } return inSampleSize; } }6. 二次封装与调试技巧把SDK变成自己的基础设施理解了原理和源码接下来就可以做二次封装了。很多人直接用SDK的原生接口导致页面代码里到处是AdManager.get().loadBanner(this, ..., new Listener(){...})的匿名内部类代码冗余严重而且回调里的Activity引用极其容易泄漏。我的建议是做一个统一的中台封装层。6.1 统一广告管理器的设计我项目里设计了一个AdCentralManager把所有广告位的加载、展示、回调收敛到一个门面类public class AdCentralManager { private MapString, AdLoadListener listeners new ConcurrentHashMap(); public void preloadBanner(String adUnitId) { AdLoader.loadAd(new AdSlot.Builder(adUnitId).setAdType(BANNER).build(), new SimpleAdListener() { Override public void onAdLoaded(AdWrapper ad) { CachePool.get().putAd(adUnitId, ad); } }); } public void showBanner(String adUnitId, ViewGroup container) { AdWrapper cached CachePool.get().getAd(adUnitId); if (cached null) { loadAndShow(adUnitId, container); return; } BannerAdView bannerView new BannerAdView(container.getContext()); bannerView.renderAd(cached); container.addView(bannerView); } }这样的好处是业务方不需要关心广告SDK回调的线程切换中台统一在回调里切好主线程广告位的加载和展示分离可以实现提前预加载展示时秒开未来替换SDK厂商时业务层代码一行不用改只改中台内部实现。6.2 抓包调试广告请求的方式很多复杂问题靠Log根本看不出所以然必须抓包看请求体。广告SDK的流量基本都是HTTPS抓包需要配置代理和安装证书。我的建议是使用Charles或者mitmproxy在测试机上安装代理证书然后做一次完整的启动App → 发起广告请求 → 加载成功 → 展示的流程。重点关注这几个字段appId和adUnitId是否是线上配置的介质IDID不一致时服务器会返回空包设备信息字段osVersion、model、networkType是否符合预期如果设备信息不完整广告平台的定向策略会失效导致填充率下降返回的物料JSON结构是否包含imageUrl、jumpUrl、clickUrl、impressionUrl任何一个字段缺失都可能导致渲染空白或点击无响应。市面上很多广告诊断工具需要在SDK里主动开Debug模式但我更推荐直接用抓包工具因为它能看到最原始的数据交互不受SDK日志隐藏的影响。6.3 性能指标排查的三个维度接入广告SDK后性能监控要盯住三个维度第一是加载耗时。从发起加载到onAdLoaded回调的时长超过2秒就需要告警。我见过一个项目接入的SDK在网络差的环境下加载耗时能到15秒原因是SDK内部请求串行排队没有做并发。后来在聚合层上加了并发请求加载耗时压到了1.2秒左右。第二是内存占用。广告SDK的图片缓存、WebView缓存都是内存大户。我在线上项目里预留了一个AdMemoryMonitor定时检查广告模块的堆内存占比超过阈值就清理缓存。这个方法在不使用广告的场景下自动回收能有效降低OOM风险。第三是崩溃率。广告SDK的崩溃率不能简单按照普通模块的崩溃率来评估因为它涉及WebView、视频播放器、图片解码等多个高风险组件。要在崩溃日志里增加一个ad_module的tag标识方便快速过滤和定位而不是在崩溃统计平台里一条条翻。实际写码之外的几个经验之谈这套广告SDK的原理与源码骨架我前后在三个项目上验证过从日请求量十几万到上千万都扛住了。比较大的感悟有两点一是不要盲信SDK文档。文档里写的初始化方法、缓存策略可能只是最简单的情形真正线上跑的版本和开源版有大量差异。遇到问题的时候与其去查文档猜参数不如自己抓包看一条真实请求问题往往瞬间就清楚了。二是广告模块一定要隔离。不只是代码层面的模块化还要做到线程隔离、内存隔离、崩溃隔离。最好把广告SDK放到一个单独的进程里这样即使广告SDK崩溃也不会影响主进程的稳定性代价是跨进程通信会有一些性能损耗。如果App对稳定性要求很高这个取舍是值得的。有一点要特别提醒接入广告SDK时一定要关注隐私合规。Android 13以下的设备可以通过ACCESS_FINE_LOCATION等权限拿到精确位置但广告SDK对位置的采集必须遵循用户授权的结果不能越权获取。自Android 13起READ_PHONE_STATE、ACCESS_FINE_LOCATION等权限都需要运行时动态申请SDK初始化时不应当主动去申请这些权限否则极容易被应用商店判定违规。合规是广告SDK的生命线这条线不能踩。最后说一个实用调参技巧开屏广告的加载时机建议不要放在Application.onCreate因为开屏页出现之前首帧如果已经加载完成用户会感觉闪了一下屏我一般把加载放在开屏页的onCreate并设置一个3到5秒的加载超时超过超时就直接进主页面不给用户造成等待焦虑。这个设计看起来是小事但对用户体验和广告收益的影响都很大。