Android制作卡片类长图踩坑记

在实现需求过程中,产品喜欢上了分享大图卡片。但是发现每个地方分享的卡片大图还不一样。经过分析发现还是能够找到一点共性。先上图看看最基础的卡片样子。由于分享的卡片在四张图片上下都需要自定义一些其他控件,主要有文字商品信息等等。

图1

在实现之前先思考了一下,需要解决的问题有以下几个:

  1. 因为每个地方的分享大图是不一样的,需要找出共性部分,并将不同之处方便替换;
  2. 在卡片中的几张图片还没下载完就吊起分享肯定是不行的。这就需要监听图片下载了;
  3. 分享到的App对大图都是有大小限制的,其中微博限制最大2M,这样就需要进行图片压缩。

###公共部分

在实现的过程中,需要一个layout文件,先将公共部分设置好,像上半部分和下半部分都是共有的,所以直接放在布局中。在布局的最外层需要使用到ScrollView,因为卡片可能会很长,只有ScrollView能够达到这种长图效果。此时在布局中需要预留出一个位置来放置下载好的图片,预留的包裹图片的控件叫做mShareImgContainer,在mShareImgContainer的上下也需要各预留一个ViewGroup,放置会变化的东西。公共的部分都很好做,下面仔细说一下下载图和放置图的处理部分。

###非公共部分

图2

如果没有非公共部分,可以不使用此方法。非公共部分由于分享的内容不同会经常变动ui效果,因此这里采用了策略模式,将分享图片占位的顶部和底部留出,作为自定义部分,要使用此方法的需要自定义上下两部分的可以自制一个策略类,并将此方法设置到ShareImgManager中。具体代码如下:

ShareImgManager shareImgManager = new ShareImgManager(getContext());
shareImgManager.setShareImgStrategy(new ShareUserCommentImg(getContext(), goodsComment, mShowGoodsComment.getGoods()));//自定义ui部分
shareImgManager.addImgBottomPart();

其中的ShareUserCommentImg类实现了ShareImgManager.ShareImgStrategy接口,接口包含了两个方法,分别是分享大图的上下两个部分,可以将相应的view指定进去。只要返回相应的view,在ShareImgManager里面会将这两部分加入进去,看下这个类的具体代码:

public class ShareUserCommentImg implements ShareImgManager.ShareImgStrategy {
private Context mContext;
private GoodsComment mGoodsComment;
private CommentGoods mCommentGoods;
public ShareUserCommentImg(Context context, GoodsComment goodsComment, CommentGoods commentGoods) {
    this.mContext = context;
    mGoodsComment = goodsComment;
    mCommentGoods = commentGoods;
}

@Override
public View imgTopPart() {
    return null;
}

@Override
public View imgBottomPart() {
    if (mCommentGoods == null) return null;
    View mShareView = LayoutInflater.from(mContext).inflate(R.layout.share_user_comment_img_bottom_part, null);
    TextView commentGoodsTitle = (TextView) mShareView.findViewById(R.id.comment_goods_title);
    KaolaImageView belongGoodsIv = (KaolaImageView) mShareView.findViewById(R.id.belong_goods_iv);
    TextView commentGoodsPrice = (TextView) mShareView.findViewById(R.id.comment_goods_price);
    TextView shareCommentContent = (TextView) mShareView.findViewById(R.id.share_comment_content);
    ImageLoaderManager.startLoad(new ImageLoaderBuilder().setKaolaImageView(belongGoodsIv)
            .setImgUrl(mCommentGoods.getImageUrl()).setWidthHeight(35, 35));
    commentGoodsPrice.setText("¥" + StringUtils.formatFloat(mCommentGoods.getActualCurrentPriceForApp()));
    commentGoodsTitle.setText(mCommentGoods.getTitle());
    shareCommentContent.setText(Html.fromHtml("<font color=\"#D22147\">@" + mGoodsComment.getNicknameKaola() + ": </font>" +
            mGoodsComment.getCommentContent()));
    return mShareView;
}

}

###显示图片的部分

创建好加载图片的ImageView控件之后,要add进去。此时我在做的时候发现了一个问题就是控件虽然add进去了,也看到占了很大的位置,但是只能显示一片空白,并没有图片显示。后来发现,是因为我们使用的是Facebook的Fresco下载图片框架问题,fresco在控件未attach到当前视图的时候是不会去下载图片的,要在ondraw之后才会去下载图片,因此在塞完控件之后只要draw一下,fresco就会异步去下载图片。看一下这个方法的代码:

public static Bitmap getBitmap(ScrollView scrollView) {
    int width = scrollView.getWidth();
    int height = scrollView.getHeight();
    scrollView.setBackgroundColor(Color.WHITE);
    if (0 == width || 0 == height) {
        scrollView.measure(View.MeasureSpec.makeMeasureSpec(750, View.MeasureSpec.EXACTLY),
                View.MeasureSpec.makeMeasureSpec(0, View.MeasureSpec.UNSPECIFIED));
        scrollView.layout(0, 0, scrollView.getMeasuredWidth(), scrollView.getMeasuredHeight());
        width = scrollView.getWidth();
        height = scrollView.getHeight();
    }
    Bitmap bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.RGB_565);
    final Canvas canvas = new Canvas(bitmap);
    scrollView.draw(canvas);
    return bitmap;
}

此时虽然进行了measure等一系列操作,但是只是占了位置,图片还没显示上去,所以要等图片下载完成之后再执行一遍才可以看到图片。

接下来就是下载图片了,在下载图片的时候需要一个计数器,只有图片全部下载完成之后才吊起分享功能。不然会出现某一个白色的情况。

计数器到达规定数量的时候,意味着图片已经下载完,此时再次调用上面的方法,重新绘制一遍,就会生成一个可分享的Bitmap,但是此时还并不能作为分享图片,因为图片可能过大,所以需要压缩。

###压缩图片的部分

图片压缩方法主要有两种:

#####1.质量压缩

质量压缩,方法如下:

image.compress(Bitmap.CompressFormat.JPEG, 50, baos);

其中第一个参数为所需要压缩的图片格式,可选三个格式JPEG,PNG和WEBP,试过这三个格式,发现JPEG的压缩方式是最好的。PNG是无损压缩,WEBP如果需要压缩的话,耗时是JPEG的好几倍,而且压缩效果也不好。所以不是推荐的压缩格式。第二个参数是需要压缩到的图片质量,第三个参数是图片输入流。

关于压缩的原理,看了一下源码,发现谷歌使用的也是libjpeg这个被广泛使用的开源JPEG图像处理库,android并没有直接使用libjpeg,而是使用了一个封装库Skia,通过Skia来使用libjpeg的。Skia对libjpeg进行了良好的封装,基于这个引擎可以很方便为操作系统、浏览器等开发图像处理功能。libjpeg在压缩图像的时候,有一个参数叫optimize_coding,官方文档中是这样写的:

boolean optimize_coding

TRUE causes the compressor to compute optimal Huffman coding tables
for the image. This requires an extra pass over the data and
therefore costs a good deal of space and time. The default is
FALSE, which tells the compressor to use the supplied or default
Huffman tables. In most cases optimal tables save only a few percent
of file size compared to the default tables. Note that when this is
TRUE, you need not supply Huffman tables at all, and any you do
supply will be overwritten.

意思就是如果设置optimize_coding为TRUE,将会使得压缩图像过程中基于图像数据计算哈弗曼表,由于这个计算会显著消耗空间和时间,默认值被设置为FALSE。谷歌的Skia项目工程师们最终没有设置这个参数,optimize_coding在Skia中默认的等于了FALSE,这就意味着更差的图片质量和更大的图片文件。关于为什么Android图片比ios差,可以参考这篇文章:为什么Android的图片质量会比iPhone的差?

采用这种方法进行压缩,并不会减少图片的像素,官方文档也解释说, 它会让图片重新构造, 但是有可能图像的位深(即色深)和每个像素的透明度会变化,也就是说以jpeg格式压缩后, 原来图片中透明的元素将消失.所以这种格式很可能造成失真。

#####2.尺寸压缩

尺寸压缩主要使用到的是BitmapFactory.Options这个参数,BitmapFactory.Options的成员变量主要有:

public Bitmap inBitmap;//重用Bitmap
public boolean inJustDecodeBounds;//设置只去读图片的附加信息(宽高),不去解析真实的Bitmap
public int inSampleSize;// 设置图片的缩放比例(宽和高)
public int inDensity;
public int inTargetDensity;
public int inScreenDensity;
public boolean inScaled;
public int outWidth;
public int outHeight;
public String outMimeType;

inJustDecodeBounds:这个参数设置为true的时候,BitmapFactory通过decodeResource或者decodeFile解码图片时,将会返回空(null)的Bitmap对象,这样可以避免Bitmap的内存分配,但是它可以返回Bitmap的宽度、高度以及MimeType。

inSampleSize:在获取了图片的宽高之后,在知道屏幕宽高的情况下,就可以通过两个的比例对图片进行压缩到屏幕尺寸进行显示。inSampleSize就是缩放比例,但是这个变量必须设置2的倍数,如果设置的非2的倍数,他也会自动变换成最近的2的指数倍数。例如:inSampleSize=4,宽高都会变为原来的1/4,整体就会缩小16倍,压缩比例还是很可观的,但是压缩到之前的16倍效果上就会差很多。

inPreferredConfig:

Bitmap.Config ARGB_4444:每个像素占四位,即A=4,R=4,G=4,B=4,那么一个像素点占4+4+4+4=16位

Bitmap.Config ARGB_8888:每个像素占四位,即A=8,R=8,G=8,B=8,那么一个像素点占8+8+8+8=32位

Bitmap.Config RGB_565:每个像素占四位,即R=5,G=6,B=5,没有透明度,那么一个像素点占5+6+5=16位

Bitmap.Config ALPHA_8:每个像素占四位,只有透明度,没有颜色。

#####3.我使用的压缩方法

public static Bitmap compressImage(Bitmap image, int minSize) {
    ByteArrayOutputStream baos = new ByteArrayOutputStream();
    image.compress(Bitmap.CompressFormat.JPEG, 100, baos);
    int bitmapSize = baos.toByteArray().length / 1024;
    if (bitmapSize / minSize < 4) {//图片需要缩小到1/4以内,用质量压缩
        int options = minSize * 200 / bitmapSize;
        while (baos.toByteArray().length / 1024 > minSize && options > 0) {
            // 重置baos
            baos.reset();
            // 这里压缩options%,把压缩后的数据存放到baos中
            image.compress(Bitmap.CompressFormat.JPEG, options, baos);
            // 每次都减少10
            options -= 10;
        }
        byte[] array = baos.toByteArray();
        if (options >= 30) {
            return decodeByteArray(array, 0, array.length);
        }
    }
    //图片需要缩小到1/4以上,用尺寸压缩
    BitmapFactory.Options opts = new BitmapFactory.Options();
    opts.inPreferredConfig = Bitmap.Config.RGB_565;
    opts.inSampleSize = 1;
    Bitmap bitmap = image;
    image.compress(Bitmap.CompressFormat.JPEG, 100, baos);
    byte[] array = baos.toByteArray();
    while (bitmap.getByteCount() / 1024 > 2048) {
        opts.inSampleSize *= 2;
        bitmap = BitmapFactory.decodeByteArray(array, 0, array.length, opts);
    }
    return bitmap;
}

###分享方法的使用

接下来就是ShareImgManager的使用过程,因为之前已经设置好了公共和非公共部分,所以在这里只要设置部分数据,并且调用createImgCard方法即可,看下代码:

ShareImgManager shareImgManager = new ShareImgManager(getContext());
shareImgManager.setShareImgStrategy(new ShareUserCommentImg(getContext(), goodsComment, mShowGoodsComment.getGoods()));//自定义ui部分
shareImgManager.addImgBottomPart();
ShareImgManager shareImgManager = new ShareImgManager(getContext());
shareImgManager.setShareImgStrategy(new ShareUserCommentImg(getContext(), goodsComment, mShowGoodsComment.getGoods()));//自定义ui部分
shareImgManager.addImgBottomPart();
shareImgManager.setData(getContext(), "http://www.kaola.com/product/" + mShowGoodsComment.getGoods().getGoodsId() + ".html",R.drawable.ic_comment_share_head);//设置二维码链接和头部的背景图
shareImgManager.createImgCard(getContext(), imgUrls, new ShareImgManager.ShareImgListener() {
      @Override
      public void createPicSuccess(String imgName) {
           GoodsDetailUtils.shareComment(getActivity(), imgName, mCommentListLv);
      }

      @Override
      public void createPicFailed() {
           ToastUtils.show("网络请求失败");
      }
});

###参考文档和延伸阅读

  1. App图片压缩裁剪原理和上传方案,以及那些有趣的事儿…
  2. Android图片编码机制深度解析(Bitmap,Skia,libJpeg)
  3. Android Bitmap 优化(1) - 图片压缩
  4. https://github.com/bither/bither-android-lib
  5. 非常全面的 Android Bitmap 知识点梳理
  6. 你的 Bitmap 究竟占多大内存?