Android-RecyclerViewItemDecoration的进阶使用

  1. ItemDecoration实现padding
  2. ItemDecoration实现下划线
  3. ItemDecoration实现酷炫吸顶效果
  4. ItemDecoration实现item的拖拽,平移等操作
Read more   2017/10/29 posted in  Android

Android-事件分发机制详解:史上最全面、最易懂

前言

  • Android事件分发机制是Android开发者必须了解的基础
  • 网上有大量关于Android事件分发机制的文章,但存在一些问题:内容不全、思路不清晰、无源码分析、简单问题复杂化等等
  • 今天,我将全面总结Android的事件分发机制,我能保证这是市面上的最全面、最清晰、最易懂的
Read more   2017/10/28 posted in  Android

Android-史上最全解析Android消息推送解决方案

前言

消息推送在Android开发中应用的场景是越来越多了,比如说电商产品进行活动宣传、资讯类产品进行新闻推送等等。

本文将介绍Android中实现消息推送的7种主流解决方案。

Read more   2017/10/27 posted in  Android

Android-RecyclerView自定义ItemDecoration从入门到实现吸顶效果

RecyclerView性能和自由度相比ListView强大很多,但很恼人的是它没有像ListView一样默认提供分割线.

刚接触RecyclerView,用过才发现RecyclerView没有分割线过后,遂到网上搜解决办法才发现自定义一个ItemDecoration只要一条黑线还要写代码,好麻烦,不知道有没像我一样懒得折腾上网搜现成的,粘贴到项目直接用.

拖了很久才去解决这个问题,上网大致看了一下教程,其实不难而且自定义功能很强大.

首先新建一个类覆写ItemDecoration里面有三个方法:

public class SimpleItemDecoration extends RecyclerView.ItemDecoration {

    public SimpleItemDecoration(Context context) {

    }

    
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);
    }

    
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);

    }


    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
    }
}
  • onDraw名字很熟悉吧,和View中的onDraw一样,是用来画东西的, 在item上画分割线就靠这个方法了.
  • onDrawOver 英文Over的意思在...的上面 ,可以理解成是图层关系,item的内容和分割线是第一层(要在第一层画东西要调用onDraw),而onDrawOver是第二层,位于onDraw的上面
  • getItemOffsets 看名字可以知道是设置item的偏移值,其实效果和padding一样.

以上三个方法都是在RecylerView发生滑动的时候触发.

需要注意的是三个方法的都有一个RecyclerView parent,通过这个参数我们可以获取到RecyclerView的属性,例如 parent.getChildCount();获取子View的个数,但是这个并不是获取RecyclerView所有的item个数,而是当前屏幕可见的item个数.

所以画一条分割线需要的代码是这样的:

    private int wight;
    private int height;
    private int item_height;
    private int item_padding;
    private Paint paint;

public SimpleItemDecoration(Context context) {

        wight=context.getResources().getDisplayMetrics().widthPixels;
        height=context.getResources().getDisplayMetrics().heightPixels;
        paint=new Paint(Paint.ANTI_ALIAS_FLAG|Paint.DITHER_FLAG);
        paint.setColor(Color.BLACK);
        item_height=DensityUtil.dip2px(context, 1);
        item_padding=DensityUtil.dip2px(context, 10);
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);
        int count=parent.getChildCount();
        for (int i = 0; i < count; i++) {
            View view=parent.getChildAt(i);
            int top=view.getTop();
            int bottom=top+item_height;
            c.drawRect(0,top,wight,bottom,paint);

        }

         .....
    }

运行后得到如下图的效果.

20171108151014780724646.png
20171108151014780724646.png

接着把item_height=DensityUtil.dip2px(context, 1);的1改成30,你会发现item的内容和黑色的分割线重合了

20171108151014785773153.png
20171108151014785773153.png

因为上面说了item和内容和onDraw中画的内容在同一图层,当然会被出现重合的情况.这个时候getItemOffsets就能派上用场了.只要在原来的item的加个偏移值(效果和在Adpater中为item设置padding的效果是一样的,只是在ItemDecoration统一处理比较合适)onDraw中画的分割线有多高,我就paddingBottom多少.

所以代码是是这样的:

  public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        outRect.bottom=item_height;
    }

再次运行代码item被挡住的问题就解决了,RecyclerView的自定义ItemDecoration就是这么简单.有点自定义View经验的人理解起来都不会难

20171108151014788332810.png
20171108151014788332810.png

分割线不要占满,要有和Left,Right有间距啊?

添加如下代码:

    private int wight;
    private int height;
    private int item_height;
    private Paint paint;
    private float item_padding;

    public SimpleItemDecoration(Context context) {

        wight=context.getResources().getDisplayMetrics().widthPixels;
        height=context.getResources().getDisplayMetrics().heightPixels;
        paint=new Paint(Paint.ANTI_ALIAS_FLAG|Paint.DITHER_FLAG);
        paint.setColor(Color.BLACK);
        item_height=DensityUtil.dip2px(context, 1);
        item_padding=DensityUtil.dip2px(context, 10);
    }

    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);
        int count=parent.getChildCount();
        for (int i = 0; i < count; i++) {
            View view=parent.getChildAt(i);
            int top=view.getBottom();
            int bottom=top+item_height;
            //这里把left和right的值分别增加item_padding,和减去item_padding.
            c.drawRect(item_padding,top,wight-item_padding,bottom,paint);

        }
          ....
    }

20171108151014792933867.png
20171108151014792933867.png

一般用到的分割线根据以上的代码再自己的按照需求稍微修改一下基本都能满足需求了.

之前在网上看到通过自定义ItemDecoration实现仿照旧版的instagram吸顶效果,感觉那种效果很好看,研究了一下发现只要理解了上面文章所说的几个方法实现起来并不难.

先来看最终效果图:

2017110815101479719495.gif
2017110815101479719495.gif

要实现吸顶的效果需要完成这些步骤:

  • 首先需要画一条高度足够容下文字和图片的分割线.
  • 因为是吸顶效果,所以分割线和传统的分割线一样应该是在每个item的上方而不是下方
  • 当前屏幕可见的第一个item的Bottom<=item_height(分割线的高度) 说明可见的第一个item的底部已经超出了分割线的高度,这个时候就应该让第一条分割线随着RecyclerView向上滑动直到滑出屏幕,这个时候第二个item就取代了第一个item变成了第一个item,否则分割线一直固定不动.
  • 判断当前屏幕的第一个可见的item是哪个
  • 把当前屏幕可见的item进行对比,如果item的内容第一个字相同,则把它们归为一组,用一条分割线显示即可.

先来实现1和2的要求,主要代码部分如下:

private int wight;
    private int height;
    private int item_height;
    private Paint paint;
    private float item_padding;

    public SimpleItemDecoration(Context context) {

        wight=context.getResources().getDisplayMetrics().widthPixels;
        height=context.getResources().getDisplayMetrics().heightPixels;
        paint=new Paint(Paint.ANTI_ALIAS_FLAG|Paint.DITHER_FLAG);
        //更改画笔颜色为自定义的颜色
        paint.setColor(context.getResources().getColor(R.color.itemColor));
        item_height=DensityUtil.dip2px(context, 30);
        item_padding=DensityUtil.dip2px(context, 10);
    }


@Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        //因为分割线是在item的上方,所以需要为每个item腾出一条分割线的高度
        outRect.top=item_height;

    }

@Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);
        int count=parent.getChildCount();
        for (int i = 0; i < count; i++) {
            View view=parent.getChildAt(i);
            //分割线不能和item的内容重叠,所以把分割线画在getItemOffsets为item腾出来的位置上.
            //所以top需要上移item_height
            int top=view.getTop()-item_height;
            //bottom同理
            int bottom=top+item_height;
            c.drawRect(0,top,wight,bottom,paint);
        }
    }

....

最终实现的效果如图:

20171108151014809478336.png
20171108151014809478336.png

注意看陈奕迅这个item的分割线是在item的上面的,并且分割线的高度已经足够容下我们稍后要绘制的内容了.

接着来实现3,怎么样才能让分割线在满足条件的时候动,不满足的时候固定?

这个时候就需要用到代码中一直没覆写的onDrawOver方法了,先来实现固定不动的分割线,代码也是非常的简单,在原来的代码上覆写onDrawOver方法
(这里new了新的画笔paint2,把固定的分割线用半透明红色来作为背景,方便理解效果):

paint2=new Paint(Paint.ANTI_ALIAS_FLAG|Paint.DITHER_FLAG);
paint2.setColor(Color.parseColor("#52ff0000"));
  
@Override
public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
    super.onDrawOver(c, parent, state);
}

20171108151014817074489.gif
20171108151014817074489.gif

接着来实现实现: 当前屏幕可见的第一个item的Bottom<=item_height(分割线的高度)让第一条分割线随着RecyclerView向上滑动直到滑出屏幕,代码如下:

@Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);
        View child0 = parent.getChildAt(0);

        //如果第一个item的Bottom<=分割线的高度
        if (child0.getBottom() <= item_height) {
            //随着RecyclerView滑动 分割线的top=固定为0不动,bottom则赋值为child0的bottom值.
            c.drawRect(0, 0, wight,child0.getBottom() , paint2);
        } else {
            //固定不动
            c.drawRect(0, 0, wight, item_height, paint2);

        }
    }

20171108151014821936037.gif
20171108151014821936037.gif

可以看到滑动时当第二item的顶部和第一个item的底部相互接触到后继续滑动的话第一个item就会慢慢向上滑动,直到第一个item完全画出屏幕,固定分割线立马回到最开始的位置和item2分割线重叠了在一起
,现在可以把paint2换回paint效果会更直观,不上效果图了,可以自己去测试.

对第一次接触ItemDecoration的人来说,难点都已经讲完了,剩下的就是在分割线范围计算出合适的位置调动drawText和drawBitmap画下文字和图片,直接贴上完整的源码:

(在源码注释里面已经把没有讲到的方法大致提了一下实现的原理)

自定义ItemDecoration的代码:

/**
 * Created by Lipt0n on 2017/8/26.
 */

public class SimpleItemDecoration extends RecyclerView.ItemDecoration {


    private Bitmap bitmap;
    private Paint.FontMetrics fontMetrics;
    private int wight;
    private int itemDecorationHeight;
    private Paint paint;
    private ObtainTextCallback callback;
    private float itemDecorationPadding;
    private TextPaint textPaint;
    private Rect text_rect=new Rect();
    public SimpleItemDecoration(Context context, ObtainTextCallback callback) {

        wight=context.getResources().getDisplayMetrics().widthPixels;
        paint=new Paint(Paint.ANTI_ALIAS_FLAG|Paint.DITHER_FLAG);
        paint.setColor(context.getResources().getColor(R.color.itemColor));
        itemDecorationHeight=DensityUtil.dip2px(context, 30);
        itemDecorationPadding=DensityUtil.dip2px(context, 10);
        this.callback = callback;



        textPaint = new TextPaint(Paint.ANTI_ALIAS_FLAG | Paint.DITHER_FLAG);
        textPaint.setColor(Color.WHITE);
        textPaint.setTextAlign(Paint.Align.LEFT);
        textPaint.setTextSize(DensityUtil.dip2px(context, 25));
        fontMetrics = new Paint.FontMetrics();
        textPaint.getFontMetrics(fontMetrics);

        bitmap= BitmapFactory.decodeResource(context.getResources(), R.mipmap.ic_launcher_round);
        ScaleBitmap();
    }

    //bitmap的大小和itemDecorationHeight进行比较对图片进行缩放操作(对性能有追求可以在加载到内存的时候进行压缩)
    private void ScaleBitmap() {
        Matrix matrix=new Matrix();
        float scale=bitmap.getWidth()>itemDecorationHeight?Float.valueOf(itemDecorationHeight)/Float.valueOf(bitmap.getHeight()):Float.valueOf(bitmap.getHeight())/Float.valueOf(itemDecorationHeight);
        matrix.postScale(scale,scale);
        bitmap= Bitmap.createBitmap(bitmap,0,0,bitmap.getWidth(),bitmap.getHeight(),matrix,false);
    }



    @Override
    public void onDraw(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDraw(c, parent, state);
        int count=parent.getChildCount();
        for (int i = 0; i < count; i++) {
            View view=parent.getChildAt(i);
            int top=view.getTop()-itemDecorationHeight;
            int bottom=top+itemDecorationHeight;


            int position = parent.getChildAdapterPosition(view);
            String content = callback.getText(position);
            textPaint.getTextBounds(content,0, content.length(),text_rect);

            if(isFirstInGroup(position)) {
                c.drawRect(0,top,wight,bottom,paint);
                c.drawText(content, itemDecorationPadding+bitmap.getWidth(), bottom-fontMetrics.descent, textPaint);
                c.drawBitmap(bitmap,itemDecorationPadding,bottom-bitmap.getHeight(),paint);
            }
        }
    }

    @Override
    public void onDrawOver(Canvas c, RecyclerView parent, RecyclerView.State state) {
        super.onDrawOver(c, parent, state);

        View child0=parent.getChildAt(0);
        int position = parent.getChildAdapterPosition(child0);
        String content = callback.getText(position);
        if(child0.getBottom()<=itemDecorationHeight&&isFirstInGroup(position+1)){
            c.drawRect(0, 0, wight, child0.getBottom(), paint);
            c.drawText(content, itemDecorationPadding+bitmap.getWidth(), child0.getBottom()-fontMetrics.descent, textPaint);
            c.drawBitmap(bitmap,itemDecorationPadding,child0.getBottom()-bitmap.getHeight(),paint);
        }
        else {
            c.drawRect(0, 0, wight, itemDecorationHeight, paint);
            c.drawText(content, itemDecorationPadding+bitmap.getWidth(), itemDecorationHeight-fontMetrics.descent, textPaint);
            c.drawBitmap(bitmap,itemDecorationPadding,itemDecorationHeight-bitmap.getHeight(),paint);
        }
    }


    @Override
    public void getItemOffsets(Rect outRect, View view, RecyclerView parent, RecyclerView.State state) {
        super.getItemOffsets(outRect, view, parent, state);
        int position= parent.getChildAdapterPosition(view);
        //如果不是在同一组就腾出分割线需要的高度
        if(isFirstInGroup(position)){
            outRect.top=itemDecorationHeight;
        }

    }

    //回调接口,通过该回调获取item的内容的第一个文字
    public interface ObtainTextCallback {
        String getText(int position);
    }

    //判断当前item和下一个item的第一个文字是否相同,如果相同说明是同一组,不需要画分割线
    private boolean isFirstInGroup(int pos) {
       //如果是adapter的第一个position直接return,因为第一个item必须有分割线
        if (pos == 0) {
            return true;
        } else {
             //否者判断前一个item的字符串 与 当前item字符串 是否相同
            String prevGroupId = callback.getText(pos - 1);
            String groupId = callback.getText(pos);          
            if (prevGroupId.equals(groupId)) {
                return false;
            } else {
                return true;
            }
        }
    }
}

Activity中调用的代码:

recyclerView.addItemDecoration(new SimpleItemDecoration(this, new SimpleItemDecoration.ObtainTextCallback() {
    @Override
    public String getText(int position) {
        return dataList.get(position).substring(0,1);
    }
}));

只要理解了最开始提到的ItemDecoration 的3个主要方法,再处理一下文字分组的逻辑实现起来不会太难,花点耐心还是能写出来的.

贴上github源码地址

2017/10/26 posted in  Android

Android-拍照获取缩略图以及完整图片(适配androidN)

调用系统相机拍照获取缩照片略图

调用系统相机拍照时,如果不传路径,图片默认返回缩略图,不需要权限

Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
    startActivityForResult(takePictureIntent, REQUEST_IMAGE_CAPTURE);
}

takePictureIntent.resolveActivity(getPackageManager()) != null

在官方文档中有描述:startActivityForResult()方法受到调用resolveActivity()的条件的保护,该方法返回可处理该意图的第一个活动组件,执行此检查很重要,因为如果您使用没有应用程序可以处理的意图调用startActivityForResult(),则您的应用程序将崩溃。所以只要结果不为空,就可以安全的使用意图,大概意思是检测手机中有没有相机。

另外一种检测相机的方法是

<manifest ... >
    <uses-feature android:name="android.hardware.camera"
                  android:required="true" />
    ...
</manifest>

required=true表示要安装该应用,手机必须有摄像头该硬件。要不然不允许安装

处理回调

@Override
protected void onActivityResult(int requestCode, int resultCode, Intent data) {
    if (requestCode == REQUEST_IMAGE_CAPTURE && resultCode == RESULT_OK) {
        Bundle extras = data.getExtras();
        Bitmap imageBitmap = (Bitmap) extras.get("data");
        mImageView.setImageBitmap(imageBitmap);
    }
}

调用系统相机拍照获取全尺寸照片

如果要保存一个全尺寸的照片,必须提供一个完整的文件名,当照片需要保存到公有目录时,那么需要一个写入的权限(写入权限已经隐含的允许读取[READ_EXTERNAL_STORAGE],这样子就可以将app拍的照片写入到外部存储,该外部存储的链接是getExternalStoragePublicDirectory()

<manifest ...>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
    ...
</manifest>

当需要将得到的照片保存到私有目录时,使用该链接getExternalFilesDir(),getFilesDir(),这两个目录下的文件在应用删除的时候就自动删掉了,在android4.4以下需要权限,4.4以上应用之间不能被其他程序访问,因此该权限只要在4.4以下加入

<manifest ...>
    <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
                     android:maxSdkVersion="18" />
    ...
</manifest>
  • 提供一个不会冲突的文件名,例如按时间来起名
String mCurrentPhotoPath;

private File createImageFile() throws IOException {
    // Create an image file name
    String timeStamp = new SimpleDateFormat("yyyyMMdd_HHmmss").format(new Date());
    String imageFileName = "JPEG_" + timeStamp + "_";
    File storageDir = getExternalFilesDir(Environment.DIRECTORY_PICTURES);
    File image = File.createTempFile(
        imageFileName,  /* prefix */
        ".jpg",         /* suffix */
        storageDir      /* directory */
    );

    // Save a file: path for use with ACTION_VIEW intents
    mCurrentPhotoPath = image.getAbsolutePath();
    return image;
}
  • 构造拍照intent,适配7.0以及4.0
static final int REQUEST_TAKE_PHOTO = 1;

    private void camera2() {
        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        // Ensure that there's a camera activity to handle the intent
        if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
            // Create the File where the photo should go
            File photoFile = null;

            try {
                photoFile = createImageFile();
            } catch (IOException ex) {
                // Error occurred while creating the File
            }
            // Continue only if the File was successfully created
            if (photoFile != null) {
                Uri photoURI = FileProvider.getUriForFile(this,
                        "lsp.com.ipctest.fileprovider",
                        photoFile);

                //解决4.0
                List<ResolveInfo> resInfoList = getPackageManager()
                        .queryIntentActivities(takePictureIntent, PackageManager.MATCH_DEFAULT_ONLY);
                for (ResolveInfo resolveInfo : resInfoList) {
                    String packageName = resolveInfo.activityInfo.packageName;
                    grantUriPermission(packageName, photoURI, Intent.FLAG_GRANT_READ_URI_PERMISSION
                            | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
                }


                takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, photoURI);
                startActivityForResult(takePictureIntent, REQUEST_TAKE_PHOTO);
            }
        }
    }

FileProvider.getUriForFile()用来返回一个content:// URI。对于最新的针对Android 7.0(API级别24)的应用程序,通过一个包边界传递一个文件:// URI会导致FileUriExposedException
鸿洋的博客关于android7.0 以及 4.0 拍照封装的处理(点击跳转)

  • 取得结果
if (requestCode == REQUEST_TAKE_PHOTO && resultCode == RESULT_OK) {
    Bitmap imageBitmap = BitmapFactory.decodeFile(mCurrentPhotoPath);
    Log.e(TAG, "文件大小" + imageBitmap.getByteCount() / 1024 + "kb");
    ((ImageView) findViewById(R.id.img)).setImageBitmap(imageBitmap);
}
  • 将照片保存到相册如果你保存图片的路径是getExternalFilesDir() 媒体扫描器访问不到,只有你自己的应用可以访问,因此一下方法可以将图片保存到相册
private void galleryAddPic() {
    Intent mediaScanIntent = new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE);
    File f = new File(mCurrentPhotoPath);
    Uri contentUri = Uri.fromFile(f);
    mediaScanIntent.setData(contentUri);
    this.sendBroadcast(mediaScanIntent);
}
2017/10/24 posted in  Android

Android-关于android UI适配的一些思考

关于xml中写死dp的思考

首先我们应该先把问题抛出,如果我们在xml把控件的宽度和高度写死,比如

 <TextView
            android:layout_width="100dp"
            android:layout_height="50dp"
            android:gravity="center"
            android:text="asdasdasd"
            />

相信大多时候都可以这么写,因为Android dp这个单位就是为了适配屏幕而出现的控件长度单位,它会让100dp在不同的手机不同的屏幕尺寸都有相似的表现。
为什么是相似的表现而不是绝对的表现呢?因为不同的设备,横向和纵向所拥有的dp很可能是不同的,一般手机横向dp在360dp左右,也就是说,如果你写了一个宽度为180dp的控件,在一些手机可能有屏幕的一般宽,有一些手机超过一般,有一些手机不到一半。

我们写这样的一段代码,然后看一下xml的预览效果:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:orientation="vertical"
    android:background="#123333"
    android:id="@+id/container"
    android:layout_width="360dp"
    android:layout_height="match_parent">

</LinearLayout>

20171108151007149290806.png
20171108151007149290806.png

20171108151007150182264.png
20171108151007150182264.png

20171108151007150657381.png
20171108151007150657381.png

关于java动态写控件大小的思考

我们看到360dp在不同设备的所表现的占屏比是不同的。如果我们写数值比较小的dp相信直接写死的问题不大。但是如果设计稿上某个控件的宽度你换算完刚好是340dp怎么,肯定不能写340dp。其实我们可以用match_parent然后用padding margin之类的东西,在左右留一个小数值dp的距离,来实现效果。但是如果这个控件要求是高度和宽度的比例是固定的,比如展示一个广告浮层的图片,那么xml估计就无法锁定宽高比了,我们就必须借助java代码来决定这个控件高度:

RelativeLayout.LayoutParams mLayoutParams = 
new RelativeLayout.LayoutParams (mHeight,mWidth);
mLayoutParams.addRule(RelativeLayout.ALIGN_TOP, R.id.supernatant);
mLayoutParams.addRule(RelativeLayout.ALIGN_LEFT, R.id.supernatant);
bigSupernatantImgLayoutParams.setMargins(DPIUtil.dip2px(9f), 0, 0, 0);
bigImg.setLayoutParams(mLayoutParams);

类似这样宽度和高度都是活的,我们可以通过获取屏幕的实际宽高像素,来通过等比,相似等一些算法,转换出比例和UI设计图一样的UI,但是最大的弊端应该就是,这么书写会让java代码比较多,比较乱。因此会有一些百分百布局框架等,其实思路都类似,等比缩放就是很核心思路。
有个轻量的方法也就是写个工具类算出设计图到手机屏幕的转换关系:

public static int getHeightByValue720(int mValue) {
return (int) ((float) (DPITools.getHeight() * nDesignValue) / (float) 1280);
    }

public static int getWidthByValue720(int mValue) {
return (int) ((float) (DPITools.getWidth() * nDesignValue) / (float) 720);
    }

这个方法就是如果是720的设计稿,我们将设计稿的值转换为在所用设备下同比例的大小。这似乎很完美。
如果设计给的控件大小是 100X200 ,那么如果运行在1080p的设配上。我们动态得到控件的大小是150X300.很开心,1080的横向纵向像素是720的1.5倍,控件也大了1.5倍这,的确没毛病。但是我们可能低估了安卓阵营了。

关于动态宽高写布局的一些思考。

三星Galaxy S8分辨率: 2960*1440 (570 ppi)

如果按照上述方式我们在三星Galaxy S8上运行效果会是如何呢?结果是200X462。控件已经倍拉伸了,原因就是S8的屏幕比例不是16比9所以,按照原来的方式缩放,就会造成拉伸,为此市面上也有解决方案:

public static int getValueByValue720(int mValue) {
return (int) ((float) (DPITools.getWidth() * nDesignValue) / (float) 720);
    }

就是无论宽度还是高度,都是用宽度缩放,那么刚才控件在S8上得到的数值就是200X400.控件不会被拉伸,由于现在大多界面都是可以Scroll的,那么就算高度不标准问题也不大,我们比如一个listview我们保证在16比9的手机上,能正好展示4个item,在16比10的手机上展示3个半item,在18.5比9的设备上展示4个半item,这个设计产品还是用户都是可以接受的。

关于动态宽度为基准写布局的一些思考。

然而关于方法三又存在一些问题,设想下面一个场景,页面里展示的是一个cardview,cardview的背景是一张图片,所以cardview宽高必须固定,这个cardview又是不允许上下滑动的,里面又有很多控件,在16比9的设计稿上,cardview里面的控件,排列整齐,最后也没什么太大的边界。

这样面临一个问题,如果在16比10的手机上,其实每次计算出的高度都是大于手机比例的,因此cardview后面的几个控件可能无法正常显示,或被拉伸。在18.5比9的手机上,cardview下面可能有空余,或者根据不同layout方式,可能其他地方有空余。我认为这还是可以接受的,比较这种手机是少数,但是控件被挤压就难以接受了。归纳起来也就说,如果这种不能上下滑动的view,可以让它有空余,但是不能让它挤压。我们可以使用一个保守的方法,判断手机是否是大于16比9,如果大于就说明手机比较瘦高,如果小于就说明手机比较胖。我们就可以用相对充裕的方法计算控件宽高,来保证控件不被挤压。

public static boolean bigThan169() {
        float h = DPIUtil.getHeight();
        float w = DPIUtil.getWidth();
        if ((h / w) > 1.78f) return true;
        else return false;
    }
if (bigThan169())
newWidth = DPIUtil.getWidthByDesignValue720(DesignWidth);(以宽度为基准)
else newWidth = DPIUtil.getHeightByDesignValue720(DesignWidth);(以高度为基准 从而保证控件上下高度够用)

总之就这就是一个保守,保证控件装得下的思路,若果是控件横向被挤压也是一样的。我们为了保证显示的下,缩小了控件。

总结:UI适配愈走愈远,有时也要和设计师产品经理协调,不要设计一些容易触发适配问题的页面,减少安卓端的适配压力,但是如果场景真的无法避免,我们就只能有更优雅的方式去解决适配问题。

2017/10/23 posted in  Android

Android-爱奇艺APK瘦身经验

APK瘦身的价值

用户常常避免下载太大的APP,尤其是使用移动流量的情况,而且太大的APP也会占用更多的内存并消耗更多的资源,导致安装速度和加载速度变慢,在低配手机上,这些情况尤其严重。

作为中国互联网领先的手机APP,爱奇艺非常重视APP客户端的用户体验,始终关注APK的体积,并持续的跟进优化。

目前爱奇艺Android APK大小指标在视频行业甚至整个移动互联网已经处于领先地位,下面是我们在APK瘦身之路上的一些经验分享。

APK组成结构

在使用一些很酷的方法,来减少应用程序的大小之前,必须先了解实际的APK文件格式。

简单地说,APK是一个包含文件/文件夹的压缩文件。作为一个开发者,我们可以很容易的通过打开压缩文件的方式查看到APK里面的内容。

7zip打开APK后的视图

20171107151006945474877.png
20171107151006945474877.png

各个文件或文件夹的功能

文件/文件夹 作用/功能
res 包含所有没有被编译到.arsc里面的资源文件
lib 引用库的文件夹
assets assets文件夹相比于res文件夹,还有可能放字体文件、预置数据和web页面等,通过AssetManager访问
META_INF 存放的是签名信息,用来保证apk包的完整性和系统的安全。在生成一个APK的时候,会对所有的打包文件做一个校验计算,并把结果放在该目录下面
classes.dex 包含编译后的应用程序源码转化成的dex字节码。APK里面,可能会存在多个dex文件
resources.arsc 一些资源和标识符被编译和写入这个文件
Androidmanifest.xml 编译时,应用程序的AndroidManifest.xml被转化成二进制格式

爱奇艺 APK各组成部分的占比情况

20171107151006957888426.png
20171107151006957888426.png

通过爱奇艺Android客户端APK组成的饼状图可以看出,APK里面占较大比重的是libs,res,dex这三块。

APK瘦身方案

通过上面的分析,已经了解了APK的基本构成。下面我们就采用多种手段进行APK瘦身

针对整体优化

插件化

从应用功能扩张的角度看,APK包体积的增大是必然的,然而插件技术的出现很好的解决了这个问题。

通过分离应用中比较独立的模块,然后以插件的形式进行加载,比如爱奇艺Android客户端有很多的相对独立的功能,游戏,漫画,文学,电影票,应用商店等,都是通过插件的方式,从服务器下载,然后以插件的方式加载到我们的主工程。

7ZIP压缩

一般情况下面,AS直接编译生成的APK里面,.arsc文件是没有进行任何压缩的,前文中APK组成部分的第一张图就可以看出。

下面,我们来解压APK,重新用7zip进行压缩,就会发现几乎所有文件都变小了,特别是.arsc文件,减小的比较多。

20171107151006967981234.png
20171107151006967981234.png

对比7zip压缩前和压缩后APK里面文件的变化,可以看出通过7zip压缩,.arsc文件大概减小了2M多,其它文件/文件夹体积也减小了5%左右。

签名方式

Google在Android7.0系统提供了新的apksigner签名工具,相比使用java提供的jarsigner签名工具,APK体积可以减小约5%(依赖文件数量)。

我们来看一下两种不同签名方式所带来的APK体积变化

20171107151006973045110.png
20171107151006973045110.png

第一个APK是未签名的,第二个是使用jarsigner签名的,第三个是使用apksigner签名生成。可以看出,使用apksigner签名比使用jarsigner签名生成的APK减小了1.1M。

那么再来看一下这两种APK签名后的文件大小差异在哪里

20171107151006976254798.png
20171107151006976254798.png

上图中间是未签名的APK,左边是jarsigner签名的,右边是apksigner签名的。

对比未签名的APK,用jarsigner签名工具签名,APK里面所有压缩后的文件和文件夹体积都增大了;而apksigner签名工具签名,除了META_INF文件夹增大了以外,其它文件和文件夹的大小都没有改变。

产生上述变化的原因是:jarsigner是针对每个文件进行了签名,然后针对签名后的文件计算摘要,并写入到META-INF文件夹下的MANIFEST.MF文件里面;而apksigner直接计算所有文件的摘要,写入MANIFEST.MF文件。

新的apksigner工具,已经集成到Android 7.0 SDK中了,使用方法可以参考官方文档

瘦身前后APK对比

在不同的版本通过不同方式进行APK瘦身的详情图,如下:

20171107151006982752667.png
20171107151006982752667.png

插件化是2年前所做的优化,7zip压缩和签名方式都是最近的优化方案,并且通过jenkins自动化脚本实现的。

针对资源优化

移除重复的资源

一套资源

Android在适配图片资源的时候,如果只有一套资源,低密度手机会缩放图片,高密度手机会拉伸图片。我们利用这个特性,存放一套资源图就可以供所有密度的手机使用。

综合考虑图片清晰度,静态大小和内存占用情况,一般采用xhdpi下的资源图片。

重复资源

很多时候,随着工程的增大,以及开发人员的变动,有些资源文件名字不同,但是内容却完全相同。我们可以通过扫描文件的MD5值,找出名字不同,内容相同的图片并删除,做到图片不重复。

移除无用的资源

由于项目的迭代以及UI改版等各种因素,会导致工程项目里面有许多无用的资源的存在,定期扫描处理无用资源。

通过Lint工具扫描工程资源

当Lint工具扫描发现无用资源的时候,会输出如下的信息,就可以删除这种资源。

res/layout/preferences.xml: Warning: The resource R.layout.preferences appears
    to be unused [UnusedResources]

需要特别注意的是,需要确保不存在反射,资源拼接等访问这些资源,才可以安全的删除掉这些资源,从而减小资源个数。

通过Gradle参数配置

如果工程比较大,由主工程和多个子工程组成的话,子工程里面也可能包含很多的无用资源。可以通过设置shrinkResources=true让Gradle移走无用的资源,否则默认情况下,Gradle编译只会移除无用代码,而不会关心无用资源。

android {
    // Other settings
 
    buildTypes {
            release {
                    minifyEnabled true
                    shrinkResources true
                    proguardFiles
getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

需要特别注意的是shrinkResources依赖于minifyEnabled,必须和minifyEnabled一起用,即打开shrinkResources也必须打开minifyEnabled。

通过开源扫描工具

大家可能会发现Lint不是非常好用,当工程里面存在反射,过滤结果非常麻烦。

所以我们实现了一个资源扫描的工具(https://github.com/zhuzhumouse/ScanUnusedResouce ),可以过滤掉通过反射调用的资源。

原理就是把所有java和xml文件以字符串扫描到内存,然后拿到资源文件(xml,png,jpg等)名称做匹配查找,如果没有匹配到,该资源就是无用资源,可以直接删除。

该扫描工具可以解决反射调用的问题,但是不能解决资源拼接的问题,还有就是不能处理存在很多资源前缀相同的情况。

png图片压缩

可以通过使用图片压缩工具对png图片进行压缩,压缩效果比较好的工具有:pngcrush,pngquant,zopflipng等,可以在保持图片质量的前提下,缩减图片的大小。

还可以通过网站对图片进行压缩,如比较有名的www.tinypng.com,该网站对上传的图片自动选择合适的压缩算法,压缩比比较高,但是只支持500张免费图片,更多图片处理是要收费的。

采用WebP格式

WebP分为有损压缩,无损压缩以及包含透明度的有损压缩。

有损WebP是基于VP8视频编码中的预测编码方法来压缩图像数据;无损WebP基于使用不同的技术对图像数据进行转换;有损WebP(支持透明度)区别于有损WebP和无损WebP,这种编码允许对RGB频道的有损编码同时可对透明度频道进行无损编码。

目前4.2及以上的手机系统已经支持WebP的无损和有损压缩,但是4.0,4.1的手机系统只支持不含透明度的有损压缩。如果应用支持的最低版本(minSdkVersion)是4.0,那么就只能针对不含透明度的图片进行WebP转换了。

在Android Studio 2.3版本及以上,我们可以选中 drawable 和 mipmap 文件夹,右键后选择 convert to webp,将图片转为 WebP 格式。如果Android Stuido版本比较低的话,可以直接通过官方提供的cwebp工具,将png转换为WebP。

下面是两张png转WebP的详情对比图

png (KB) WebP 75 (KB) WebP 90 (KB)
120 2.7 5.78
10 15 27

从以上两张样图的转换结果看,不是所有的图片都有高压缩比,有些图片压缩后反而会增大,比如第二张样图。WebP对色差比较小的图片,压缩比会比较高,任何一种压缩算法只能针对具有某种特点的图片进行压缩,没用万能压缩方法。

大背景图处理

对清晰度要求高的大图片,采用单纯的压缩方法就不能满足UE的要求了,需要找到一种非压缩方式来解决这个问题。

纯色图+后台下载的方式很好的解决了这个问题,客户端先使用纯色图片,然后大图从后端下载,这样只是启动的前几次使用纯色图,以后都会使用大图。

Lottie动画库的使用

动画,尤其是帧动画,一直都是相当占用资源的。现在可以通过Airbnb公司开源的Lottie动画库,直接用json文件来描述动画,然后直接加载绘制出来。

具体使用参考

其它资源策略

  1. 首先考虑能否不用图片,比如使用shape代码实现。
  2. 其次如果用图片的话,能否优先使用.9图来简化图片。
  3. 采用svg矢量图和VectorDrawable类来替换传统的图片。
  4. 如果图片只是旋转角度或者颜色不同,可以用代码实现变换。

资源瘦身前后APK对比

爱奇艺客户端使用到的资源优化方案详情如图所示

20171107151007036555517.png
20171107151007036555517.png

目前爱奇艺客户端使用了这四种资源优化方式。

原来客户端xhdpi和xxhdpi下面有一部分重叠的资源,删除后包体积缩减了1M;移除无用资源是通过自己的扫描工具,获取无用资源列表,然后确认处理;pngquart压缩是打包过程中通过gradle自动化脚本实现的;WebP格式是通过python脚本,遍历查找不含透明度的图片,然后进行WebP转化替换原图片的。

针对代码优化

上面已经详细的介绍了资源文件的优化方法,通过这些优化,包体积得到明显的缩减,下面我们再来讨论一下代码的优化。

代码混淆

在gradle使用minifyEnabled进行Proguard混淆的配置,可大大减小APP大小:

android {
    buildTypes {
        release {
            minifyEnabled true
            proguardFile('groguard.cfg')
        }
    }

下面是代码混淆前后APK的详情

20171108151007057113444.png
20171108151007057113444.png

尤其需要注意的是:在proguard中,是否保留符号表对APP的大小是有显著的影响的,可酌情不保留,但是建议尽量保留用于调试。

无用代码扫描

同无用资源扫描方式一样,可以针对无用的代码进行扫描,这里需要关注的一点就是在插件里面通过反射的方法调用的主应用的一些类和方法是不能删除的。

也可以使用SonarQube扫描无用类,以及不同类里面的重复代码。

详情请参考

剔除R文件

随着项目中资源的增加,会发现生成的dex文件里面R.class文件越来越大。我们知道真正使用资源的地方都是以R.xxx.xxx这种方式访问的,而R.xxx.xx是对应于.arsc文件里面的一个常量值。arsc里面的内容具体如下:

字符串资源在.arsc文件里面的存储方式

20171108151007068485270.png
20171108151007068485270.png

Layout下面的Xml资源文件在.arsc文件里面的存储方式

20171108151007070585423.png
20171108151007070585423.png

通过这两张截图我们可以看出,直接用ID替换资源访问代码R.XXX.XXX,这样R.class文件就没有任何作用了,可以删除它,并且代码里面的资源访问字符串也变成了常量,两个方面都减小了dex的大小。

剔除R文件可以参考开源工具

注解替代枚举

谷歌官方一直强烈推荐用注解替代枚举,一方面可以缩减包体积,另一方便可以节省内存开销。我们来对比一下,在使用注解和使用枚举两种情况下,生成的class文件内容。

枚举类型源码

public enum MarkViewType3{
    SIMPLE_TEXT_MARK,
    DO_LIKE_MARK,
    BOTTOM_BANNER1,
    BOTTOM_BANNER2,
    TL_GREY_BACKGROUND_RANK,
    /**
     *服务导航mark
     */
    SERVICENAVIRIGHTMARK,
    /**
     *搜索页热点事件,标题、评论、事件
     */
    BOTTOM_COMPOUND_TEXT_BANNER
}

编译生成dex后的class文件

public enum MarkViewType3
{
  static
  {
    DO_LIKE_MARK = new MarkViewType3("DO_LIKE_MARK", 1);
    BOTTOM_BANNER1 = new MarkViewType3("BOTTOM_BANNER1", 2);
    BOTTOM_BANNER2 = new MarkViewType3("BOTTOM_BANNER2", 3);
    TL_GREY_BACKGROUND_RANK = new MarkViewType3("TL_GREY_BACKGROUND_RANK", 4);
    SERVICENAVIRIGHTMARK = new MarkViewType3("SERVICENAVIRIGHTMARK", 5);
    BOTTOM_COMPOUND_TEXT_BANNER = new MarkViewType3("BOTTOM_COMPOUND_TEXT_BANNER", 6);
    $VALUES = new MarkViewType3[] { SIMPLE_TEXT_MARK, DO_LIKE_MARK, BOTTOM_BANNER1, BOTTOM_BANNER2, TL_GREY_BACKGROUND_RANK, SERVICENAVIRIGHTMARK, BOTTOM_COMPOUND_TEXT_BANNER };
  }
}

通过对比可以看到生成的class文件里面,每个变量都是一个对象,并且还有一个value对象数组。

注解的实现源码

public class MarkViewType1{
    public static final int SIMPLE_TEXT_MARK = 0;
    public static final int DO_LIKE_MARK = 1;
    public static final int BOTTOM_BANNER1 = 2;
    public static final int BOTTOM_BANNER2 = 3;
    public static final int TL_GREY_BACKGROUND_RANK = 4;
    /**
     *服务导航mark
     */
    public static final int SERVICENAVIRIGHTMARK = 5;
    /**
     *搜索页热点事件,标题、评论、事件
     */
    public static final int BOTTOM_COMPOUND_TEXT_BANNER = 6;
    @IntDef ({SIMPLE_TEXT_MARK, DO_LIKE_MARK, BOTTOM_BANNER1, BOTTOM_BANNER2, TL_GREY_BACKGROUND_RANK
            , SERVICENAVIRIGHTMARK, BOTTOM_COMPOUND_TEXT_BANNER})
    @Retention(RetentionPolicy.SOURCE)
    public @interface MarkViewType1Anno{
    }
}

生成的class文件

public class MarkViewType1
{
  public static final int BOTTOM_BANNER1 = 2;
  public static final int BOTTOM_BANNER2 = 3;
  public static final int BOTTOM_COMPOUND_TEXT_BANNER = 6;
  public static final int DO_LIKE_MARK = 1;
  public static final int SERVICENAVIRIGHTMARK = 5;
  public static final int SIMPLE_TEXT_MARK = 0;
  public static final int TL_GREY_BACKGROUND_RANK = 4;
 
  @Retention(RetentionPolicy.SOURCE)
  public static @interface MarkViewType1Anno
  {
  }
}

注解生成的class文件只是一些常量。

通过上面的代码对比可以看出,常量+注解的形式,一方面可以减小生成的class文件的字节数,另一方面可以减小内存开销。

代码瘦身前和瘦身后APK对比

爱奇艺客户端代码优化详情如图所示

20171108151007085241950.png
20171108151007085241950.png

由上图可以看出,代码混淆可以很大程度的减小包体积,尤其是引入了比较多点的第三方库的情况。所以打包的时候,应该开启代码混淆,以及资源混淆。

注解替代枚举,经过尝试,发现大量修改之后,对缩减包体积帮助不大,所以爱奇艺客户端没有采用该方案。

arsc文件优化

在剔除R文件小节中,大家已经看到了.arsc文件内容格式。在整体优化小节中,已经对.arsc进行了比较大的优化,接下来分析一下其它优化方式。

可以采用混淆来缩减资源文件的名称,以及移除未使用的备用资源等方式来优化.arsc文件。如何移除未使用的备用资源,gradle里面

增加如下配置:

android {
    defaultConfig {
        ...
            resConfigs "zh", "zh_CN", "zh_HK", "zh_MO", "zh_TW", "en"
    }
}

通过该方式,爱奇艺客户端包体积可以缩减100多KB。

lib目录优化

只提供对主流架构的支持,比如arm,对于mips和x86架构可以考虑不提供支持,系统会自动提供相应的兼容。爱奇艺客户端只在armeabi下面放置了一套so库文件。

除了插件化,客户端还是用了RN的方案,从而引入了RN的so库。由于RN的so库资源比较大,有2M多,进而引入了RN的so库的插件化。通过so库的插件化,来缩减包体积。RN库的插件化,包体积就缩减了1M多。

包瘦身详情总结

通过上面所有方式进行瘦身,APK变化详情,如下图所示:

20171108151007094932891.png
20171108151007094932891.png

由上图可以看出,经过代码优化,资源优化,lib库优化,.arsc文件优化,及整体优化,包体积由54.2M缩减到28.2M。

瘦身过程中遇到的问题

WebP支持问题

WebP图片的转化过程中,一定要注意资源拼接的情况。

比如如果存在vip_1,vip_2,vip_3,vip_4,vip_5等五个资源,要么都转化成WebP,要么都不转,不能处理其中的一部分。

替换一些引导图的时候,一定要打包工具和客户端同时替换。如果客户端把引导图替换成了WebP格式,而打包的时候,由于不同步,该图片又被替换成png格式,就会导致资源加载不成功,进而程序崩溃。

签名方式

使用apksigner签名工具前,必须先执行zipalign操作;而使用jarsigner签名工具则是先签名,然后再用zipalign优化。

小结

目前爱奇艺Android客户端主要通过插件化、RN、签名方式、7zip压缩、保留一套资源、代码资源混淆、无用资源处理、剔除R文件、图片压缩等方式来缩减包体积,包体积整体缩减了20M多。

缩减包体积是一个长期的任务,未来还有很多事情需要做,比如定期扫描无用代码和资源资源、图片持续优化、矢量图、Lottie动画的大量使用等等,随着新技术的涌现,我们会有更多的方法去缩小包体积,使得应用更轻盈运行速度更快。

2017/10/21 posted in  Android

Android-轻松自制flyme悬浮球

前言

去年用了一整年的MX4Pro,魅族留给我最大的印象就是悬浮球了(质量问题我就不说了),左右滑动切换应用、上拉返回桌面、下拉打开通知栏、轻触返回…,一切都那么丝滑。然而自从上半年换成了s7dege,我感觉怎么也习惯不了没有悬浮球的生活了。

三星自己也有一个类似于悬浮球的功能,不过太过复杂,不易用,悬浮球本来就该是一个一步操作的产品,看来三星在软件设计方面还是任重而道远。于是乎我便在各大应用市场上找悬浮球,把所有排名靠前的悬浮球应用都安装试了一下,最后终于让我找到了一款几乎和flyme悬浮球相仿的app。

这款app在我手机里呆了好几个月,是我手机里除了微信之外,唯一允许自启动的应用了。很感谢这款app的开发者,不仅没有任何广告,还非常好用,完美移植了flyme自带的悬浮球功能。

然而渐渐的,我便感觉到了一丝不舒服,那就是我每次安装了一个新app,打开后提示要赋予权限(存储、拍照)的时候,6.0的系统总会温馨的弹出一个框:

然后我就必须到设置页面,花半天找到悬浮球,关掉它的“可出现在顶部的应用程”权限,然后才能回到app,授予权限。最后,我还得再次跑到设置页面,再花半天找到悬浮球,打开它的“可出现在顶部的应用程”权限。朋友啊朋友,这种体验,一次就够了,然而硬是让我体验了N次啊!

然而有什么能难得倒程序员的呢?刚好这个周末在家无事,我决定按照自己的习惯,打造一个心目中最易用的悬浮球。

设计

UI

UI很简单,直接用sketch切了三个圆,一个是作为背景的灰色半透明的圆,一个是中心的小圆,另外还有一个默认隐藏的大圆。

功能

因为自己的操作习惯是固定的,所以也就不需要给悬浮球添加自定义操作的功能了,直接将操作对应的功能写死即可。

  1. 单击:返回
  2. 长按:移动悬浮球
  3. 左滑右滑:打开最近应用程序
  4. 上拉:返回桌面
  5. 下拉:

这块我最先开始定义的很简单,就是下拉通知栏,但是经过一天的使用,我又给它加了一个功能,就是保持下拉状态1.5秒,将移除悬浮球。这样你便可以很简单的移除掉悬浮球了。

实现

如何添加悬浮球到桌面

这里首先要感谢郭霖大神的 《 Android桌面悬浮窗效果实现,仿360手机卫士悬浮窗效果》,这部分我参考了这篇文章,成功的将悬浮球添加到了桌面。

public static void addBallView(Context context) {
    if (mBallView == null) {
        WindowManager windowManager = getWindowManager(context);
        int screenWidth = windowManager.getDefaultDisplay().getWidth();
        int screenHeight = windowManager.getDefaultDisplay().getHeight();
        mBallView = new FloatBallView(context);
        LayoutParams params = new LayoutParams();
        params.x = screenWidth;
        params.y = screenHeight / 2;
        params.width = WindowManager.LayoutParams.WRAP_CONTENT;
        params.height = WindowManager.LayoutParams.WRAP_CONTENT;
        params.gravity = Gravity.LEFT | Gravity.TOP;
        params.type = LayoutParams.TYPE_PHONE;
        params.format = PixelFormat.RGBA_8888;
        params.flags = LayoutParams.FLAG_NOT_TOUCH_MODAL
                | LayoutParams.FLAG_NOT_FOCUSABLE;
        mBallView.setLayoutParams(params);
        windowManager.addView(mBallView, params);
    }
}

手势判断

这是最重要的部分了,承担着悬浮球的主要功能。

手指按下时

按下时,隐藏小球,展现大球,并记录按下位置和按下时间。

case MotionEvent.ACTION_DOWN:
       mIsTouching = true;
       mImgBall.setVisibility(INVISIBLE);
       mImgBigBall.setVisibility(VISIBLE);
       mLastDownTime = System.currentTimeMillis();
       mLastDownX = event.getX();
       mLastDownY = event.getY();
       postDelayed(new Runnable() {
               @Override
               public void run() {
                   if (isLongTouch()) {
                       mIsLongTouch = true;
                       mVibrator.vibrate(mPattern, -1);
                   }
             }
       }, LONG_CLICK_LIMIT);
       break;

代码最后的postDealy时干嘛使的呢?就是通过延迟300毫秒,判断是否是长按模式。如果目前还没有处于其他模式,则可判断为长按,并震动提醒。

手指移动时

这时要判断是否是处于长按状态,如果是,那么进入MOVE模式,移动悬浮球,如果不是,则判断操作手势,即下拉还是上拉等其他手势。

case MotionEvent.ACTION_MOVE:
      if (!mIsLongTouch && isTouchSlop(event)) {
              return true;
      }
      if (mIsLongTouch && (mCurrentMode == MODE_NONE || mCurrentMode == MODE_MOVE)) {
              mLayoutParams.x = (int) (event.getRawX() - mOffsetToParent);
              mLayoutParams.y = (int) (event.getRawY() - mOffsetToParentY);
              mWindowManager.updateViewLayout(FloatBallView.this, mLayoutParams);
              mBigBallX = mImgBigBall.getX();
              mBigBallY = mImgBigBall.getY();
              mCurrentMode = MODE_MOVE;
      } else {
              doGesture(event);
      }
      break;

进行手势操作的代码如下,主要是根据当前坐标与按下时记录的坐标进行计算,判断手势,并更新大球位置。

private void doGesture(MotionEvent event) {
    float offsetX = event.getX() - mLastDownX;
    float offsetY = event.getY() - mLastDownY;

    if (Math.abs(offsetX) < mTouchSlop && Math.abs(offsetY) < mTouchSlop) {
        return;
    }
    if (Math.abs(offsetX) > Math.abs(offsetY)) {
        if (offsetX > 0) {
            if (mCurrentMode == MODE_RIGHT) {
                return;
            }
            mCurrentMode = MODE_RIGHT;
            mImgBigBall.setX(mBigBallX + OFFSET);
            mImgBigBall.setY(mBigBallY);
        } else {
            if (mCurrentMode == MODE_LEFT) {
                return;
            }
            mCurrentMode = MODE_LEFT;
            mImgBigBall.setX(mBigBallX - OFFSET);
            mImgBigBall.setY(mBigBallY);
        }
    } else {
        if (offsetY > 0) {
            if (mCurrentMode == MODE_DOWN || mCurrentMode == MODE_GONE) {
                return;
            }
            mCurrentMode = MODE_DOWN;
            mImgBigBall.setX(mBigBallX);
            mImgBigBall.setY(mBigBallY + OFFSET);

            //如果长时间保持下拉状态,将会触发移除悬浮球功能
            postDelayed(new Runnable() {
                @Override
                public void run() {
                    if (mCurrentMode == MODE_DOWN && mIsTouching) {
                        toRemove();
                        mCurrentMode = MODE_GONE;
                    }
                }
            }, TO_APP_INDEX_LIMIT);
        } else {
            if (mCurrentMode == MODE_UP) {
                return;
            }
            mCurrentMode = MODE_UP;
            mImgBigBall.setX(mBigBallX);
            mImgBigBall.setY(mBigBallY - OFFSET);
        }
    }
}

手指抬起时

手指抬起后,先要判断是否是长按模式,不是的话再判断是否是单击,都不是的话就根据当前状态触发对应功能。

case MotionEvent.ACTION_CANCEL:
case MotionEvent.ACTION_UP:
       mIsTouching = false;
       if (mIsLongTouch) {
           mIsLongTouch = false;
       } else if (isClick(event)) {
           AccessibilityUtil.doBack(mService);
       } else {
           doUp();
       }
       mImgBall.setVisibility(VISIBLE);
       mImgBigBall.setVisibility(INVISIBLE);
       mCurrentMode = MODE_NONE;
       break;

魅族小米请注意!试了魅族pro5,先点击start->进入辅助功能界面->点击无障碍->开启FloatBall辅助功能。接着还要干一件事,就是魅族自己给悬浮窗加了权限,必须进入设置->应用管理->已安装中找到floatball->权限管理->开启悬浮窗权限,小米应该也是。此处不想吐槽国产ROM

2017/10/21 posted in  Android

Android-OOM案例分析

在Android(Java)开发中,基本都会遇到java.lang.OutOfMemoryError(本文简称OOM),这种错误解决起来相对于一般的Exception或者Error都要难一些,主要是由于错误产生的root cause不是很显而易见。由于没有办法能够直接拿到用户的内存dump文件,如果错误发生在线上的版本,分析起来就会更加困难。本文从一个具体的案例切入,介绍OOM分析的思路及相关工具的使用。

案例背景

在美团App 7.4~7.7版本期间,美食业务的OOM数量居高不下,远高于历史水平,主要都是DECODE本地的资源出错。

20171107151006844360459.png
20171107151006844360459.png

图中OOM数量为各版本发版后第一个月的统计量,包含新发版本及历史版本。对比了同时期其他业务的情况,也有类似OOM。由于美食业务的访问量占美团App的比重较大,因此,OOM的数量相对其他业务也多一些。

思路方案

在问题较为严重的7.6~7.7版本期间,团队对OOM频现的原因有过各种猜测。笔者怀疑过是否是业务上某些修改引起的,例如头图尺寸变大,或者是由页面模块加载方式引起的等等。但这些与OOM问题出现的时间并不吻合。其次也怀疑过是否由某些ROM的Bug导致,但此推断缺乏有力的证据支撑。因此,要找到OOM的root cause,根本途径还是找到谁占的内存最多,然后再根据具体case具体分析,为什么占了这么多。

采集用户手机内存信息

要分析内存的占用,需要内存的dump文件,但是dump文件一般都比较大,让用户配合上传dump文件不合适。所以希望能够运行时采集一些内存的特征然后随着crash日志上报上来。当用户发生OOM时,dump出用户的内存,然后基于com.squareup.haha:haha:2.0.3分析,得到一些关键数据(内存占用最多的实例及所占比例等)。但这个方案很快就被证明是不可行的。主要基于下面几个原因:

需要引入新的库。
dump和分析内存都很耗时,效率难以接受。
OOM时内存已经几乎耗尽,再加载内存dump文件并分析会导致二次OOM,得不偿失。
模拟复现OOM

采集用户手机内存信息的方案不可行,那么只能采取复现用户场景的方式。由于发生OOM时,用户操作路径的不确定性,无法精确复现线上的OOM,因此采取模拟复现的方式,最终发生OOM时的栈信息基本一致即可。为了能够尽量模拟用户发生OOM的场景,需要基本条件基本一致,即用户使用的手机的各种相关参数。

挖掘OOM特征

分析7.4以来的OOM,列出发生OOM的机器的特征,主要是内存和分辨率,适当考虑其它因素例如系统版本。

机型 内存 分辨率 OS stack log
OPPO N1(T/W) 2G 1920 * 1080 4.2.2 java.lang.OutOfMemoryError
at android.graphics.BitmapFactory.nativeDecodeAsset(Native Method)
HM 2LTE-CMCC 1G 1280 * 720 4.4.4 java.lang.OutOfMemoryError
at android.graphics.BitmapFactory.nativeDecodeAsset(Native Method)
Newman CM810 2G 1920 * 1080 4.4.4 java.lang.OutOfMemoryError
at android.graphics.BitmapFactory.nativeDecodeAsset(Native Method)
LGL22 2G 1830 * 1080 4.2.2 java.lang.OutOfMemoryError
at android.graphics.BitmapFactory.nativeDecodeAsset(Native Method)
OPPO X909 2G 1920 * 1080 4.2.2 java.lang.OutOfMemoryError
at android.graphics.BitmapFactory.nativeDecodeAsset(Native Method)
Lenovo K900 2G 1920 * 1080 4.2.2 java.lang.OutOfMemoryError
at android.graphics.BitmapFactory.nativeDecodeAsset(Native Method)
GiONEE E6 2G 1920 * 1080 4.2.1 java.lang.OutOfMemoryError
at android.graphics.BitmapFactory.nativeDecodeAsset(Native Method)

这些特征可以总结为:内存一般,分辨率偏高,OOM的堆栈log基本一致。其中,OPPO N1(T/W)上所发生的OOM比重较高,约为65%,因此选定这款机器作为复现OOM的机器。

关键数据(内存dump文件)

需要复现OOM然后获取内存dump。思路是采取内存压力测试,让问题暴露的快速且充分。具体方案为:

  • 选取图片资源多且较为复杂的页面,比如美食的POI详情页。
  • 加载30次该页面,为了增加OOM的几率,30个POI页面的ID是不同的。

OOM发生后,使用Android Studio自带的Android Monitor dump出HPROF文件,然后使用SDK中的hprof-conv(位于sdk_root/platform-tools)工具转换为标准的Java堆转储文件格式,这样可以使用MAT(Eclipse Memory Analyzer)继续分析。

切到histogram视图,按shadow heap降序排列。

选取byte数组,右击->list objects->with incoming references,降序排列可以看到有很多大小一致的byte[]实例。

20171107151006883926615.png
20171107151006883926615.png

右击其中一个数组->Path to GC Roots-> exclude xxx references

20171107151006888292386.png
20171107151006888292386.png

如上图所示,这些byte[]都是系统的EdgeEffect的drawable所持有,drawable对应的bitmap占用的空间为1566 * 406 * 4 = 2543184,与byte数组的大小一致。

再看另外一个:

20171107151006892587007.png
20171107151006892587007.png

这些byte[]是被App的一个背景图所持有,如下图:

20171107151006895888632.png
20171107151006895888632.png

通过ImageView的ID(如图)及build目录下的R.txt反查可知该ImageView的ID名称,即可知其设置的背景图的大小为720 * 200(xhdpi),加载到内存并考虑density,size刚好是1080 * 300 * 4 = 1296000,与byte数组大小一致。

数据分析

为什么会出现这些大小一致的byte数组,或者说,为什么会创建多份EdgeEffect的drawable?查看EdgeEffect的源码(4.2.2)可知,其drawable成员也是通过Resources.getDrawable系统调用获取的。

/**
 * Construct a new EdgeEffect with a theme appropriate for the provided context.
 * @param context Context used to provide theming and resource information for the EdgeEffect
 */
public EdgeEffect(Context context) {
    final Resources res = context.getResources();
    mEdge = res.getDrawable(R.drawable.overscroll_edge);
    mGlow = res.getDrawable(R.drawable.overscroll_glow);

        ******

    mMinWidth = (int) (res.getDisplayMetrics().density * MIN_WIDTH + 0.5f);
    mInterpolator = new DecelerateInterpolator();
}

ImageView(View)获取background对应的drawable的过程类似。

for (int i = 0; i < N; i++) {
    int attr = a.getIndex(i);
    switch (attr) {
        case com.android.internal.R.styleable.View_background:
            background = a.getDrawable(attr); // TypedArray.getDrawable
            break;
        ******
    }
}

不论是Resources.getDrawable还是TypedArray.getDrawable,最终都会调用Resources.loadDrawable。继续看Resources.loadDrawable的源码,发现的确是使用了缓存。对于同一个drawable资源,系统只会加载一次,之后都会从缓存去取。

既然drawable的加载机制并没有问题,那么drawable所在的缓存实例或者获取drawable的Resources实例是否是同一个呢?通过下面的代码,打印出每个Activity的Resources实例及Resources实例的drawable cache。

//noinspection unchecked
LongSparseArray<WeakReference<Drawable.ConstantState>> cache = (LongSparseArray<WeakReference<Drawable.ConstantState>>) Hack.into(Resources.class).field("mDrawableCache").get(getResources());
Object appCache = Hack.into(Resources.class).field("mDrawableCache").get(getApplication().getResources());
Log.e("oom", "Resources: {application=" + getApplication().getResources() + ", activity=" + getResources() + "}");
Log.e("oom", "Resources.mDrawableCache: {application=" + appCache + ", activity=" + cache + "}");

20171107151006910770075.png
20171107151006910770075.png

这也进一步解释了另外一个现象,即这些大小相同的数组的个数基本和启动Activity的数量成正比。

通过数据分析可知,这些drawable之所以存在多份,是因为其所在的Resources实例并不是同一个。进一步debug可知,Resources实例存在多个的原因是开启了标志位sCompatVectorFromResourcesEnabled
虽然最终造成OOM突然增多的原因只是开启一个标志位,但是这也告诫大家阅读API文档的重要性,其实很多时候API的使用说明已经明确告知了使用的限制条件甚至风险。

7.8版本关闭了此标志,发版后第一个月的OOM数量(包含历史版本)为153,如下图。

20171107151006915714253.png
20171107151006915714253.png

其中新版本发生的OOM数量为22。

总结

对于线上出现的OOM,如何分析和解决可以大致分为三个步骤:

  1. 充分挖掘特征。在挖掘特征时,需要多方面考虑,此过程更多的是猜测怀疑,所以可能的方面都要考虑到,包括但不限于代码改动、机器特征、时间特征等,必要时还需要做一定的统计分析。
  2. 根据掌握的特征寻找稳定的复现的途径。一般需要做内存压力测试,这样比较容易达到OOM的临界值,只是简单的一些正常操作难以触发OOM。
  3. 获取可分析的数据(内存dump文件)。利用MAT分析dump文件,MAT可以方便的按照大小排序实例,可以查看某些实例到GC ROOT的路径。
2017/10/20 posted in  Android

Android-全面解析 Application类

前言

Applicaiton类在 Android开发中非常常见,可是你真的了解Applicaiton类吗?

本文将全面解析Applicaiton类,包括特点、方法介绍、应用场景和具体使用,希望你们会喜欢。

目录

20171106150989787899330.png
20171106150989787899330.png

定义

  • 代表应用程序(即 Android App)的类,也属于Android中的一个系统组件
  • 继承关系:继承自 ContextWarpper 类

特点

实例创建方式:单例模式

  • 每个Android App运行时,会首先自动创建Application 类并实例化 Application 对象,且只有一个

即 Application类 是单例模式(singleton)类

  • 也可通过 继承 Application 类自定义Application 类和实例

实例形式:全局实例

即不同的组件(如Activity、Service)都可获得Application对象且都是同一个对象

生命周期:等于 Android App 的生命周期

Application 对象的生命周期是整个程序中最长的,即等于Android App的生命周期

方法介绍

那么,该 Application 类有什么作用呢?下面,我将介绍Application 类的方法使用

20171106150989812673960.png
20171106150989812673960.png

onCreate()

  • 调用时刻: Application 实例创建时调用

Android系统的入口是Application类的 onCreate(),默认为空实现

  • 作用
    • 初始化 应用程序级别 的资源,如全局对象、环境配置变量、图片资源初始化、推送服务的注册等
      > 注:请不要执行耗时操作,否则会拖慢应用程序启动速度
    • 数据共享、数据缓存
      设置全局共享数据,如全局共享变量、方法等

注:这些共享数据只在应用程序的生命周期内有效,当该应用程序被杀死,这些数据也会被清空,所以只能存储一些具备 临时性的共享数据

  • 具体使用
// 复写方法需要在Application子类里实现

private static final String VALUE = "Carson";
    // 初始化全局变量
    @Override
    public void onCreate()
    {
        super.onCreate();  
        VALUE = 1;
    }
}

registerComponentCallbacks() & unregisterComponentCallbacks()

  • 作用:注册和注销 ComponentCallbacks2回调接口

本质上是复写 ComponentCallbacks2回调接口里的方法从而实现更多的操作,具体下面会详细介绍

  • 具体使用
registerComponentCallbacks(new ComponentCallbacks2() {
// 接口里方法下面会继续介绍
            @Override
            public void onTrimMemory(int level) {

            }

            @Override
            public void onLowMemory() {

            }

            @Override
            public void onConfigurationChanged(Configuration newConfig) {

            }
        });

onTrimMemory()

  • 作用:通知 应用程序 当前内存使用情况(以内存级别进行识别)

Android 4.0 后提供的一个API

20171106150989834178917.png
20171106150989834178917.png

  • 应用场景:根据当前内存使用情况进行自身的内存资源的不同程度释放,以避免被系统直接杀掉 & 优化应用程序的性能体验
  1. 系统在内存不足时会按照LRU Cache中从低到高杀死进程;优先杀死占用内存较高的应用
  2. 若应用占用内存较小 = 被杀死几率降低,从而快速启动(即热启动 = 启动速度快)
  3. 可回收的资源包括:
    1. 缓存,如文件缓存,图片缓存
    2. 动态生成 & 添加的View

典型的应用场景有两个:

2017110615098984255098.png
2017110615098984255098.png

  • 具体使用
registerComponentCallbacks(new ComponentCallbacks2() {

@Override
  public void onTrimMemory(int level) {

  // Android系统会根据当前内存使用的情况,传入对应的级别
  // 下面以清除缓存为例子介绍
    super.onTrimMemory(level);
  .   if (level >= ComponentCallbacks2.TRIM_MEMORY_MODERATE) {

        mPendingRequests.clear();
        mBitmapHolderCache.evictAll();
        mBitmapCache.evictAll();
    }

        });
  • 可回调对象 & 对应方法
Application.onTrimMemory()
Activity.onTrimMemory()
Fragment.OnTrimMemory()
Service.onTrimMemory()
ContentProvider.OnTrimMemory()

特别注意onTrimMemory()中的TRIM_MEMORY_UI_HIDDEN与onStop()的关系

  • onTrimMemory()中的TRIM_MEMORY_UI_HIDDEN的回调时刻:当应用程序中的所有UI组件全部不可见时
  • Activity的onStop()回调时刻:当一个Activity完全不可见的时候
  • 使用建议:
    • 在 onStop()中释放与 Activity相关的资源,如取消网络连接或者注销广播接收器等
    • 在onTrimMemory()中的TRIM_MEMORY_UI_HIDDEN中释放与UI相关的资源,从而保证用户在使用应用程序过程中,UI相关的资源不需要重新加载,从而提升响应速度

注:onTrimMemory的TRIM_MEMORY_UI_HIDDEN等级是在onStop()方法之前调用的

onLowMemory()

  • 作用:监听 Android系统整体内存较低时刻
  • 调用时刻:Android系统整体内存较低时
registerComponentCallbacks(new ComponentCallbacks2() {

  @Override
            public void onLowMemory() {

            }

        });
  • 应用场景:Android 4.0前 检测内存使用情况,从而避免被系统直接杀掉 & 优化应用程序的性能体验

类似于 OnTrimMemory()

  • 特别注意:OnTrimMemory() & OnLowMemory() 关系
    1. OnTrimMemory()是 OnLowMemory() Android 4.0后的替代 API
    2. OnLowMemory() = OnTrimMemory()中的TRIM_MEMORY_COMPLETE级别
    3. 若想兼容Android 4.0前,请使用OnLowMemory();否则直接使用OnTrimMemory()即可

onConfigurationChanged()

  • 作用:监听 应用程序 配置信息的改变,如屏幕旋转等
  • 调用时刻:应用程序配置信息 改变时调用
  • 具体使用
registerComponentCallbacks(new ComponentCallbacks2() {

            @Override
            public void onConfigurationChanged(Configuration newConfig) {
              ...
            }

        });
  • 该配置信息是指 :Manifest.xml文件下的 Activity标签属性android:configChanges的值,如下:
<activity android:name=".MainActivity">
      android:configChanges="keyboardHidden|orientation|screenSize"
// 设置该配置属性会使 Activity在配置改变时不重启,只执行onConfigurationChanged()
// 上述语句表明,设置该配置属性可使 Activity 在屏幕旋转时不重启
 </activity>

registerActivityLifecycleCallbacks() & unregisterActivityLifecycleCallbacks()

  • 作用:注册 / 注销对 应用程序内 所有Activity的生命周期监听
  • 调用时刻:当应用程序内 Activity生命周期发生变化时就会调用

实际上是调用registerActivityLifecycleCallbacks()里 ActivityLifecycleCallbacks接口里的方法

  • 具体使用
// 实际上需要复写的是ActivityLifecycleCallbacks接口里的方法
registerActivityLifecycleCallbacks(new ActivityLifecycleCallbacks() {
            @Override
            public void onActivityCreated(Activity activity, Bundle savedInstanceState) {
                Log.d(TAG,"onActivityCreated: " + activity.getLocalClassName());
            }

            @Override
            public void onActivityStarted(Activity activity) {
                Log.d(TAG,"onActivityStarted: " + activity.getLocalClassName());
            }

            @Override
            public void onActivityResumed(Activity activity) {
                Log.d(TAG,"onActivityResumed: " + activity.getLocalClassName());
            }

            @Override
            public void onActivityPaused(Activity activity) {
                Log.d(TAG,"onActivityPaused: " + activity.getLocalClassName());
            }

            @Override
            public void onActivityStopped(Activity activity) {
                Log.d(TAG, "onActivityStopped: " + activity.getLocalClassName());
            }

            @Override
            public void onActivitySaveInstanceState(Activity activity, Bundle outState) {
            }

            @Override
            public void onActivityDestroyed(Activity activity) {
                Log.d(TAG,"onActivityDestroyed: " + activity.getLocalClassName());
            }
        });

<-- 测试:把应用程序从前台切到后台再打开,看Activcity的变化 -->
 onActivityPaused: MainActivity
 onActivityStopped: MainActivity
 onActivityStarted: MainActivity
 onActivityResumed: MainActivity

onTerminate()

调用时刻:应用程序结束时调用

但该方法只用于Android仿真机测试,在Android产品机是不会调用的

应用场景

从Applicaiton类的方法可以看出,Applicaiton类的应用场景有:(已按优先级排序)

  • 初始化 应用程序级别 的资源,如全局对象、环境配置变量等
  • 数据共享、数据缓存,如设置全局共享变量、方法等
  • 获取应用程序当前的内存使用情况,及时释放资源,从而避免被系统杀死
  • 监听 应用程序 配置信息的改变,如屏幕旋转等
  • 监听应用程序内 所有Activity的生命周期

具体使用

  • 若需要复写实现上述方法,则需要自定义 Application类
  • 具体过程如下

步骤1:新建Application子类

即继承 Application 类

public class CarsonApplication extends Application
  {
    ...
    // 根据自身需求,并结合上述介绍的方法进行方法复写实现

    // 下面以onCreate()为例
  private static final String VALUE = "Carson";
    // 初始化全局变量
    @Override
    public void onCreate()
    {
        super.onCreate();

        VALUE = 1;

    }

  }

步骤2:配置自定义的Application子类

在Manifest.xml文件中 标签里进行配置

Manifest.xml

<application

        android:name=".CarsonApplication"
        // 此处自定义Application子类的名字 = CarsonApplication
    
</application>

步骤3:使用自定义的Application类实例

private CarsonApplicaiton app;

// 只需要调用Activity.getApplication() 或Context.getApplicationContext()就可以获得一个Application对象
app = (CarsonApplication) getApplication();

// 然后再得到相应的成员变量 或方法 即可
app.exitApp();

至此,关于 Applicaiton 类已经讲解完毕。

总结

我用一张图总结上述文章

20171106150989846698587.png
20171106150989846698587.png

2017/10/20 posted in  Android