七猫广告sdk激励视频流量优化

前言

目前公司正在搭建广告ADX平台,使用自研的七猫SDK渲染图文、视频广告,用户在阅读器内看书时,为了节省用户流量,视频广告在移动网络下不会播放视频,只会显示封面,但是用户选择看激励视频时,遇到了一些问题。下面分析下几种不同方案各自存在的问题,以及我们最后的解决方案。

第一种方案是不做任何缓存,直接播放,用户每次播放视频素材都会消耗一次流量;

结论:这种方案比较消耗流量。

第二种方案是等待视频下载完成再播放,在视频下载过程中,用户面对着黑屏要一直等待,直到视频下载完成,之前某家联盟广告就是采用的这种方案,线上收到很多用户反馈黑屏时间长、视频无法播放的情况,因为网络不好时,用户等待时间可能超过30s,极大的影响用户体验;

结论:观看视频体验不友好。

第三种方案是边下边播,视频在播放的同时下载资源到本地,视频在播放时需要消耗一次流量,同时视频下载会消耗一次流量,相当于第一次播放下载会消耗两倍流量,后续播放相同素材就从本地读取,不会消耗流量。

结论:这种方案也比较消耗流量。

那么有没有更好的方式,让用户体验友好的同时自始自终只消耗一倍流量呢?请耐心往下看。

最终技术方案

想要实现一倍流量,一种方案是存储播放器加载的数据到本地,下次直接从本地读书数据播放,项目中使用的是系统的mediaPlayer播放器,播放逻辑封装在c层,想要实现缓存,必须要改系统源码,实现起来难度较大,且对系统稳定性有影响。

另一种方案是在播放器与视频源服务器之间加了一层代理服务器,截取视频播放器发送的请求,根据截取的请求,向网络服务器请求数据,然后写到本地,本地代理服务器从文件中读取数据并发送给播放器进行播放。目前市面上使用上述原理的成熟框架主要有两种:AndroidVideoCacheJeffVideoCache,两者都实现了边下边播功能,且消耗一倍流量,技术原理也是一样的,下图是两者的区别。

功能对比 JeffVideoCache AndroidVideoCache
GitHub star 99 4.9k
支持视频类型 m3u8、mp4、mov mp4、mov
播放器 mediaPlayer、exoPlayer、ijkPlayer mediaPlayer、exoPlayer、ijkPlayer
视频预加载 支持 不支持
缓存清理 过期时间、文件大小 文件个数、文件大小
缓存分片 支持 不支持

依上图可知,jeffVideoCache实现的功能更加全面,但是使用的人数少,后期维护风险大,androidVideoCache也能满足节省流量的需求,由于社区活跃、功能更加稳定,因此后者更适合在我们的项目中应用。

AndroidVideoCache的应用

1.基本使用

使用builder模式构建httpProxyCacheServer对象,将服务端的url转为本地的url链接,设置给播放器,开始播放。

HttpProxyCacheServer proxy = new HttpProxyCacheServer.Builder(this).build();
 String proxyUrl = proxy.getProxyUrl(url);
 videoView.setVideoPath(proxyUrl);
 videoView.start();

2.原理简介

主要的实现原理是在本地客户端实现一个代理服务器,客户端将视频链接转为本地host链接给播放器,代理服务器的sockt监听到请求过来时,会再开一个线程,从原始的url读取数据到本地的文件,同时socket读取本地文件的数据写入,mediaPlay通过binder通信读取数据给c层,播放视频。

3.源码分析

在讲源码之前,先了解mediaplayer视频播放来源,可以是文件,也可以是网络url请求,下面是mediaPlayer源码。


private void setDataSource(String path, String[] keys, String[] values,
        List<HttpCookie> cookies)
        throws IOException, IllegalArgumentException, SecurityException, IllegalStateException {
    final Uri uri = Uri.parse(path);
    final String scheme = uri.getScheme();
    if ("file".equals(scheme)) {
      //file开头是本地文件uri,从本地读取
        path = uri.getPath();
    } else if (scheme != null) {
    
        nativeSetDataSource(
          //传递mediaHttpService给c层
            MediaHTTPService.createHttpServiceBinderIfNecessary(path, cookies),
            path,
            keys,
            values);
        return;
    }

    final File file = new File(path);
    try (FileInputStream is = new FileInputStream(file)) {
        setDataSource(is.getFD());
    }
}

nativeSetDataSource是个native方法,通过jni调用创建MediaHTTPConnection对象,内部HttpUrlConnection负责发起真正的网络请求数据。AndroidVideoCache要将请求转为本地的代理服务器的请求,需要将url配置成本地的host和端口号,下面是对url的处理流程图。

文件缓存不支持.m3u8格式的视频素材,因为它是多文件格式返回,无法做客户缓存,rtmp/rtsp开头是直播类的请求也不支持,然后对url做md5加密作为文件名,如果本地已缓存成功,播放器从本地读取数据播放,不存在则ping本地连接,成功则说明本地代理服务器可用。第一行代码使用建造者模式,配置参数。


    private static final long DEFAULT_MAX_SIZE = 512 * 1024 * 1024;
    //文件默认保存路径,有外置存储权限会保存在外置卡中,没有会保存在cache文件夹下
    private File cacheRoot;
  //文件名生成器,默认是对url进行md5加密
    private FileNameGenerator fileNameGenerator;
  //缓存清除策略,默认是LRU清理算法
    private DiskUsage diskUsage;
  //资源信息存储策略,存储url,length,mimetype,默认存储数据库
    private SourceInfoStorage sourceInfoStorage;
  //为url添加请求头,默认不添加
    private HeaderInjector headerInjector;

    public Builder(Context context) {
        this.sourceInfoStorage = SourceInfoStorageFactory.newSourceInfoStorage(context);
        this.cacheRoot = StorageUtils.getIndividualCacheDirectory(context);
        this.diskUsage = new TotalSizeLruDiskUsage(DEFAULT_MAX_SIZE);
        this.fileNameGenerator = new Md5FileNameGenerator();
        this.headerInjector = new EmptyHeadersInjector();
    }
   
   public HttpProxyCacheServer build() {
            Config config = buildConfig();
     //构建代理服务器对象
            return new HttpProxyCacheServer(config);
        }
}

创建本地代理服务器ServerSocket,最多允许8个客户端并发连接读取数据,然后创建一个线程一直等待客户端连接,这里用了CountDownLatch线程同步锁,因为创建线程耗时操作,防止服务端没有创建好就走了下面代码,出现异常情况。Sokect通信原理见下图。

public class HttpProxyCacheServer {
    
    private static final String PROXY_HOST = "127.0.0.1";
    private ExecutorService socketProcessor=Executors.newFixedThreadPool(8);

private HttpProxyCacheServer(Config config) {
    this.config = checkNotNull(config);
    try {
        InetAddress inetAddress = InetAddress.getByName(PROXY_HOST);
        this.serverSocket = new ServerSocket(0, 8, inetAddress);
        this.port = serverSocket.getLocalPort();
        IgnoreHostProxySelector.install(PROXY_HOST, port);
        CountDownLatch startSignal = new CountDownLatch(1);
        this.waitConnectionThread = new Thread(new WaitRequestsRunnable(startSignal));
        this.waitConnectionThread.start();
        startSignal.await(); 
        this.pinger = new Pinger(PROXY_HOST, port);
        HttpProxyCacheDebuger.printfLog("Proxy cache server started. Is it alive? " + isAlive());
    } catch (IOException | InterruptedException e) {
        socketProcessor.shutdown();
        throw new IllegalStateException("Error starting local proxy server", e);
    }
}
  
    private void waitForRequest() {
        try {
            while (!Thread.currentThread().isInterrupted()) {
              //此方法一直阻塞...,有请求过来时会往下走
                Socket socket = serverSocket.accept();
                socketProcessor.submit(new SocketProcessorRunnable(socket));
            }
        } catch (IOException e) {
            onError(new ProxyCacheException("Error during waiting connection", e));
        }
    }
  
  //客户端socket处理任务
    private void processSocket(Socket socket) {
        try {
            GetRequest request = GetRequest.read(socket.getInputStream());
            String url = ProxyCacheUtils.decode(request.uri);
            if (pinger.isPingRequest(url)) {
                pinger.responseToPing(socket);
            } else {
                HttpProxyCacheServerClients clients = getClients(url);
                clients.processRequest(request, socket);
            }
        } catch (SocketException e) {
        } catch (ProxyCacheException | IOException e) {
            onError(new ProxyCacheException("Error processing request", e));
        } finally {
            releaseSocket(socket);
            HttpProxyCacheDebuger.printfLog("Opened connections: " + getClientsCount());
        }
    }
  
}

socketProcessor线程池的作用是并发处理客户端请求,如果是ping请求,会向ping回写ping ok字段,socket收到之后会比对字段,相同则表示连接成功。正常请求会创建HttpProxyCacheServerClients代理客户端管理类,它主要做的是客户端代理HttpProxyCache生命周期管理和回调监听。

  
public void processRequest(GetRequest request, Socket socket) throws ProxyCacheException, IOException {
    startProcessRequest();
    try {
        clientsCount.incrementAndGet();
        proxyCache.processRequest(request, socket);
    } finally {
        finishProcessRequest();
    }
}
//创建客户端代理对象
private synchronized void startProcessRequest() throws ProxyCacheException {
    proxyCache = proxyCache == null ? newHttpProxyCache() : proxyCache;
}
//关闭客户端代理线程
private synchronized void finishProcessRequest() {
    if (clientsCount.decrementAndGet() <= 0) {
        proxyCache.shutdown();
        proxyCache = null;
    }
}
}

HtttpProxyCache是处理请求的核心类,首先会写响应头,包括content-length,accept-range,content-type等键值对,然后判断是否从文件读取缓存,从文件缓存读的条件是文件长度为0,或者request.partial为false,其实就是没有ranage字段,或者有请求头range字段,但值小于缓存文件已读取的长度加上总长度的20%,然后太大也不走文件缓存。

public void processRequest(GetRequest request, Socket socket) throws IOException, ProxyCacheException {
    OutputStream out = new BufferedOutputStream(socket.getOutputStream());
    String responseHeaders = newResponseHeaders(request);
    out.write(responseHeaders.getBytes("UTF-8"));

    long offset = request.rangeOffset;
    if (isUseCache(request)) {
        responseWithCache(out, offset);
    } else {
        responseWithoutCache(out, offset);
    }
}

private boolean isUseCache(GetRequest request) throws ProxyCacheException {
    long sourceLength = source.length();
    boolean sourceLengthKnown = sourceLength > 0;
    long cacheAvailable = cache.available();
    /**
    *Content-Length的值要大于0
    *range字段值要大于0
    *range值要比缓存偏移值大,但不能超过总长度的20%
    *NO_CACHE_BARRIER:0.2f
    **/
    return !sourceLengthKnown || !request.partial || request.rangeOffset <= cacheAvailable + sourceLength * NO_CACHE_BARRIER;
}

先看读取文件缓存的实现逻辑,我们知道一开始文件里面肯定是空的,所以要先从远程服务器里面读到文件,然后再从文件读取,先看文件的读写操作类,用的是RandomAccessFile对象,因为它既可以读,也可以写,同时还可以seek到某个位置,一般用于分段下载场景。需要注意的是,在创建文件时是在原文件名中添加了.download的后缀名,表示文件正在下载中,然后在下载完成时会重新命名成原来的名字。

public FileCache(File file, DiskUsage diskUsage) throws ProxyCacheException {
    try {
        if (diskUsage == null) {
            throw new NullPointerException();
        }
        this.diskUsage = diskUsage;
        File directory = file.getParentFile();
        Files.makeDir(directory);
        boolean completed = file.exists();
        this.file = completed ? file : new File(file.getParentFile(), file.getName() + TEMP_POSTFIX);
        this.dataFile = new RandomAccessFile(this.file, completed ? "r" : "rw");
    } catch (IOException e) {
        throw new ProxyCacheException("Error using file " + file + " as disc cache", e);
    }
}
    public synchronized int read(byte[] buffer, long offset, int length) throws ProxyCacheException {
        try {
            dataFile.seek(offset);
            return dataFile.read(buffer, 0, length);
        } catch (IOException e) {
            String format = "Error reading %d bytes with offset %d from file[%d bytes] to buffer[%d bytes]";
            throw new ProxyCacheException(String.format(format, length, offset, available(), buffer.length), e);
        }
    }
public synchronized void append(byte[] data, int length) throws ProxyCacheException {
        try {
            if (isCompleted()) {
                throw new ProxyCacheException("Error append cache: cache file " + file + " is completed!");
            }
            dataFile.seek(available());
            dataFile.write(data, 0, length);
        } catch (IOException e) {
            String format = "Error writing %d bytes to %s from buffer with size %d";
            throw new ProxyCacheException(String.format(format, length, dataFile, data.length), e);
        }
    }

读取远程数据是readSourceAsync方法,它又开启了新的线程,每次读8192个字节,一次性写完,然后socket线程每次从文件读8192个字节,然后写入到socket中,如果文件读比文件写快的话,有设置1s的同步线程锁,当文件写完一个buffer,通知这边读一个buffer,从而保证有序进行读取。

private void responseWithCache(OutputStream out, long offset) throws ProxyCacheException, IOException {
    byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
    int readBytes;
    
    while ((readBytes = read(buffer, offset, buffer.length)) != -1) {
        out.write(buffer, 0, readBytes);
        offset += readBytes;
    }
    out.flush();
}

  public int read(byte[] buffer, long offset, int length) throws ProxyCacheException {
        ProxyCacheUtils.assertBuffer(buffer, offset, length);
    
        while (!cache.isCompleted() && cache.available() < (offset + length) && !stopped) {
            readSourceAsync();
            waitForSourceData();//等待1s
            checkReadSourceErrorsCount();
        }
        int read = cache.read(buffer, offset, length);
        if (cache.isCompleted() && percentsAvailable != 100) {
            percentsAvailable = 100;
            onCachePercentsAvailableChanged(100);
        }
        return read;
    }
 private void waitForSourceData() throws ProxyCacheException {
        synchronized (wc) {
            try {
                wc.wait(1000);
            } catch (InterruptedException e) {
                throw new ProxyCacheException("Waiting source data is interrupted!", e);
            }
        }
    }

    private void notifyNewCacheDataAvailable(long cacheAvailable, long sourceAvailable) {
        onCacheAvailable(cacheAvailable, sourceAvailable);

        synchronized (wc) {
            wc.notifyAll();
        }
    }

大概的源码分析完了,总结一下就是将播放地址转为本地host和port给mediaPlayer,客户端开启serverSocket监听请求,mediaplayer发送请求时,socket解析请求,判断是否需要走文件缓存,还是走直接下载方式,文件下载成功存储到本地,下次同一url请求时直接从本地文件读取,不消耗流量。

4.结果分析

测试结果符合预期,边下边播只消耗了一倍的流量,后续播放从本地文件读取,不消耗流量,如图所示,使用charles抓包看到发起了两次网络请求,第一次只请求了很小的数据量,第二次才请求了完整的数据,为什么会请求两次呢?这和mp4的格式有关系,下图是用mediaParser工具查看到的mp4文件结构

其中 moov 记录 MP4 视频的元信息,特别是 trak记录着视频播放数据的时间和空间信息。而 mdat 则是保存着视频音频信息。而视频的拖动,快进,都是需要根据 moov 和拖动至的时间,来计算要 seek 到的文件位置。 但是 MP4 文件,不一定 moov 就在文件开头,也有可能在文件末尾。

在发起http请求时,会先读取moov头部信息(moov 一般都比较小),获取到视频的总长度,再接着往下读取;如果发现开头没有,立马 RESET 这个连接,节省流量,通过 Range 头读取文件末尾数据,因为前面一个 HTTP 请求已经获取到了 Content-Length ,知道了 MP4 文件的整个大小,通过 Range 头读取部分文件尾部数据也是可以读取到的。

思考总结

在分析源码的过程中,发现了androidVideoCache的一些不足之处:

1.视频预加载

框架不支持视频预加载功能,客户端需要手动实现预缓存,后续可以将这个功能封装到框架中。

2.线程管理

线程池创建不合理,客户端响应线程用固定线程池的方式,会增多项目线程数,引发oom,可以根据cpu的核心数创建线程个数,节约资源;new Thread方式可以改用线程池方式创建。

//客户端响应线程池
private ExecutorService socketProcessor = Executors.newFixedThreadPool(8);

//本地服务器监听线程
 this.waitConnectionThread = new Thread(new WaitRequestsRunnable(startSignal));
            this.waitConnectionThread.start();
  //读取网络数据线程
 sourceReaderThread = new Thread(new SourceReaderRunnable(), "Source reader for " + source);
            sourceReaderThread.start();

3.AndroidVideoCache采用数据库进行存储缓存的信息,可以不使用,减少IO操作。

       public Builder(Context context) {
        this.sourceInfoStorage = SourceInfoStorageFactory.newSourceInfoStorage(context);
          
        }

数据库缓存了素材的url,长度和类型,以便于下次使用时直接从数据库中查询,其实是没有必要的,因为每次请求都会创建新的对象,不需要缓存。

4.支持m3u8视频格式,视频分片下载,节省流量。

mp4格式是整段视频,每次开启下载时,都会把整个视频下载下来,m3u8格式是数据返回是分片的,视频可以一段一段下载,节省流量。

参考文献:

1.https://juejin.cn/post/6844903773521838087

2.https://zhuanlan.zhihu.com/p/395181238

3.https://blog.qiusuo.im/blog/2015/02/05/play-mp4-using-http/

展示评论