解决Picasso在Android 5.0以下版本不兼容https导致图片不显示

时间:2024-03-29 07:37:23
近期在项目中遇到了一个问题,使用picasso加载图片在Android5.0以下版本图片显示不来。
由于之前在几个项目中都使用过picasso而且未出现类似问题,觉得值得好好研究一下。
简单定位一下问题所在,我们一直使用picasso大致会是下面的代码
Picasso.with(context).load(url).into(imageView);
我们知道into函数还有另外一个版本,可以添加callback,如下:
Picasso.with(context).load(url).into(imageView, new Callback() {
    @Override
    public void onSuccess() {
    }

    @Override
    public void onError() {
    }
});
这样可以在回调中做一些事情
通过上面的回掉测试发现图片不显示是因为error了,但是picasso的callback并没有给出具体错误。
通过日志可以看到picasso给出了出错信息:
Attempting to convert network exception javax.net.ssl.SSLHandshakeException to error code.
但是这段信息量不够,隐约感觉与https证书有关。

深入调查就需要我们去追踪picasso的源码了。追踪源码可以看到请求经过OkHttpDownloader.load()和NerworkRequestHandler.load()这两层函数,最终在BitmapHunter的run函数中得到处理,这个函数源码如下:
@Override public void run() {
  try {
    updateThreadName(data);

    if (picasso.loggingEnabled) {
      log(OWNER_HUNTERVERB_EXECUTINGgetLogIdsForHunter(this));
    }

    result = hunt();

    if (result == null) {
      dispatcher.dispatchFailed(this);
    else {
      dispatcher.dispatchComplete(this);
    }
  } catch (Downloader.ResponseException e) {
    if (!e.localCacheOnly || e.responseCode != 504) {
      exception = e;
    }
    dispatcher.dispatchFailed(this);
  catch (NetworkRequestHandler.ContentLengthException e) {
    exception = e;
    dispatcher.dispatchRetry(this);
  catch (IOException e) {
    exception = e;
    dispatcher.dispatchRetry(this);
  catch (OutOfMemoryError e) {
    StringWriter writer = new StringWriter();
    stats.createSnapshot().dump(new PrintWriter(writer));
    exception new RuntimeException(writer.toString()e);
    dispatcher.dispatchFailed(this);
  catch (Exception e) {
    exception = e;
    dispatcher.dispatchFailed(this);
  finally {
    Thread.currentThread().setName(Utils.THREAD_IDLE_NAME);
  }
}
可以看到调用了dispatcher.dispatchFailed(this),这样再经过Dispatcher的处理调用callback的。

至于整个请求及处理过程涉及到的源码太多,这里就不详细来说来,有时间我们另开一章。

因为在run函数以及catch了所有exception,所以我们需要在这里来获取出错的信息,通过debug看到,加载图片出现的错误实际上是
javax.net.ssl.SSLProtocolException: SSL handshake aborted: ssl=0xb8de3a90: Failure in SSL ...

求助万能的百度后得知,这个问题的确与证书有关。这里摘录一段大神的解释,其实也是google对SSLEngine的官方说明



这里截取不同Android版本针对于TLS协议的默认配置图如下:

解决Picasso在Android 5.0以下版本不兼容https导致图片不显示

从上图可以得出如下结论:

  • TLSv1.0从API 1+就被默认打开
  • TLSv1.1和TLSv1.2只有在API 20+ 才会被默认打开
  • 也就是说低于API 20+的版本是默认关闭对TLSv1.1和TLSv1.2的支持,若要支持则必须自己打开



通过上面的解释可以知道,TLSv1.2在Android 5.0以下系统默认是关闭的,那么问题的原因就清晰了。首先是我们的图片服务器使用TLSv1.2证书,但未同步到前端开发人员,而picasso-v2.5.2底层所使用的网络框架没有为Android 5.0以下系统打开TLSv1.2导致的。

问题原因我们知道的,如何解决呢?
我们知道Picasso默认底层网络请求是HttpURLConnection,但是Picasso可以替换底层的网络请求框架的,我们使用这一功能来实现对TLSv1.2的支持。

Picasso不仅封装了HttpURLConnection,也封装了OkHttp,所以我们可以使用Picasso自带的OkHttp,经过修改后替换Picasso默认的HttpURLConnection即可,代码如下:
if(Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) {
    OkHttpClient client = new OkHttpClient();
    try {
        SSLContext sc = SSLContext.getInstance("TLS");
        sc.init(null, null, null);
        client.setSslSocketFactory(new PicassoSslSocketFactory(sc.getSocketFactory()));
    catch (Exception e) {
        e.printStackTrace();
    }

    Picasso.Builder builder = new Picasso.Builder(context);
    builder.downloader(new OkHttpDownloader(client));
    Picasso.setSingletonInstance(builder.build());
}

先判断是否是Android 5.0之下,其实这步判断也可以不加。
然后就是创建一个OkHttpClient,注意这个是Picasso包中的,不能使用OkHttp包中的同名类(因为3.0之后OkHttp的包名变了)。
OkHttpClient设置一个SslSocketFactory,如果我们不设置,在OkHttpClient中会有一个默认的SslSocketFactory,具体源码如
private synchronized SSLSocketFactory getDefaultSSLSocketFactory() {
  if (defaultSslSocketFactory == null) {
    try {
      SSLContext sslContext = SSLContext.getInstance("TLS");
      sslContext.init(null, null, null);
      defaultSslSocketFactory = sslContext.getSocketFactory();
    catch (GeneralSecurityException e) {
      throw new AssertionError()// The system has no TLS. Just give up.
    }
  }
  return defaultSslSocketFactory;
}

对比两部分代码可以发现,区别之处在client.setSslSocketFactory(new PicassoSslSocketFactory(sc.getSocketFactory()));这一句,很明显我们在sc.getSocketFactory()之外又封装了一下,PicassoSslSocketFactory这个类就是解决问题的关键,下面我们会讲到。

让我们先看后续的3行代码,这3行代码就是替换底层的网络请求框架。新建一个Picasso的Builder,然后为其设置downloader,至于Builder其他的成员则使用default对象。
最后使用setSingleLetonInstance这个函数,Picasso这个类实际上是单例模式,调用这个函数后就会将我们新建的Builder对象赋予成这个唯一的对象,之后我们使用Picasso任何其他函数实际上都会使用这个对象,这样就实现了替换。这个函数源码如下
public static void setSingletonInstance(Picasso picasso) {
  synchronized (Picasso.class) {
    if (singleton != null) {
      throw new IllegalStateException("Singleton instance already exists.");
    }
    singleton = picasso;
  }
}

可以看到如果已经赋值过,则不能再赋值,否则会报错。而如果我们使用过picasso其他函数,实际上会创建一个默认的对象,这样就无法替换了。所以替换必须在使用Picasso任何功能之前,那么就是在Application的onCreate中了。

上面实现了替换网络框架,实际上打开TLSv1.2是在PicassoSslSocketFactory中,这个类的代码如下:
public class PicassoSslSocketFactory extends SSLSocketFactory {
    private static final String[] TLS_SUPPORT_VERSION = {"TLSv1.1""TLSv1.2"};

    final SSLSocketFactory delegate;

    public PicassoSslSocketFactory(SSLSocketFactory base) {
        this.delegate = base;
    }

    @Override
    public String[] getDefaultCipherSuites() {
        return delegate.getDefaultCipherSuites();
    }

    @Override
    public String[] getSupportedCipherSuites() {
        return delegate.getSupportedCipherSuites();
    }

    @Override
    public Socket createSocket(Socket sString host, int port, boolean autoClose) throws IOException {
        return patch(delegate.createSocket(shostportautoClose));
    }

    @Override
    public Socket createSocket(String host, int port) throws IOException{
        return patch(delegate.createSocket(hostport));
    }

    @Override
    public Socket createSocket(String host, int portInetAddress localHost, int localPort) throws IOException{
        return patch(delegate.createSocket(hostportlocalHostlocalPort));
    }

    @Override
    public Socket createSocket(InetAddress host, int port) throws IOException {
        return patch(delegate.createSocket(hostport));
    }

    @Override
    public Socket createSocket(InetAddress address, int portInetAddress localAddress, int localPort) throws IOException {
        return patch(delegate.createSocket(addressportlocalAddresslocalPort));
    }

    private Socket patch(Socket s) {
        if (s instanceof SSLSocket) {
            ((SSLSocket) s).setEnabledProtocols(TLS_SUPPORT_VERSION);
        }
        return s;
    }

}

可以看到比较简单,实际上是一层代理。
所有的createSocket函数都被代理了,如果是SSLSocket,则使用setEnabledProtocols打开TLSv1.1和TLSv1.2,这样在Android 5.0以下的版本中就可以使用TLSv1.2证书了。

这样问题就解决了,看网上说新版本的picasso已经解决这个问题了,很多人说2.5.3版本但是没有找到,官方好像一直停留在2.5.2版本。说实话这个版本bug不少,之前还遇到过5.0本地图片加载失败的问题(见剖析Picasso加载压缩本地图片流程(解决Android 5.0部分机型无法加载本地图片的问题)),而目前网上能找到最新的版本是2.5.2.4b,这个应该不是官方的,虽然解决了不少问题,但是由于包名变了,如果要替换请根据项目的实际情况来。