Hongyang - 钱柜娱乐开户 http://static.blog.csdn.net/images/logo.gif 生命不息,奋斗不止,万事起于忽微,量变引起质变 /lmj623565791 zh-cn 5 2017/12/14 12:35:41 Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/78714705 /lmj623565791/article/details/78714705 lmj623565791 2017/12/5 9:45:02

本文已在我的公众号hongyang钱柜娱乐开户原创首发。
转载请标明出处:
/lmj623565791/article/details/78714705
本文出自张鸿洋的博客

一、概述

貌似前段时间刷知乎看到的一种非常有特色的广告展现方式,即在列表页,某一个Item显示背后部分广告图,随着列表滚动,会逐渐展示全部图片。

刚看到的时候就想实现一哈,一直比较懒,公众号后台也有人问如何实现,今天来给大家讲解下,当然了,目前一些自定义View已经不算难题,所以本文的讲解会做一些实现思路引导,相信不会是那么枯燥的文章,希望对大家有一定的帮助。

恩,现在知乎上已经找不到该效果了,试了多个历史版本也没找到,那只能贴实现的效果图了~

效果图如下:

2选1,你喜欢哪个效果图呢~~

二、思路

好了,抛开别的,确定下本文的目标:

实现在列表中展示某张图片:

往上滚动:在图片刚出现时展示顶部部分,随着滚动部分展示全部
往下滚动:在图片刚出现时展示底部部分,随着滚动部分展示全部

换句话说,我们需要在列表滚动时,改变图片显示的部分。

两个点:

  • 捕获列表滚动的dy,不管是ListView还是RecyclerView相信这一点都能做到
  • 图片显示部分变化,我们可以利用canvas.translate

结合一下,就是,监听列表的滚动dy,传给我们的图片控件,设置translate,然后绘制。

到这里,思路非常清晰,这个东西肯定能做了。

初步方案:自定义一个View,自己去绘制bitmap,对外暴露setDy(dy),然后根据dy做canvas偏移重绘即可。

有了初步方案,基本不慌了,那么再想想?

能否利用已有的控件,比如ImageView呢?

肯定可以,这样省去了我们去声明一个接受图片的属性,我们编写一个子类,依然是通过设置src去使用。

那继承ImageView实现一波再说。

开始码代码前,力推下我的公众号:

专注于钱柜娱乐开户的相关技术~

三、实现

首先我们先写个假的列表,鉴于RV用的越来越多,就用RecyclerView吧。

布局

主布局文件,一个RecyclerView即可:

<?xml version="1.0" encoding="utf-8"?>
<钱柜娱乐开户.support.v7.widget.RecyclerView
    xmlns:钱柜娱乐开户="http://schemas.钱柜娱乐开户.com/apk/res/钱柜娱乐开户"
    xmlns:app="http://schemas.钱柜娱乐开户.com/apk/res-auto"
    钱柜娱乐开户:id="@+id/id_recyclerview"
    钱柜娱乐开户:layout_width="match_parent"
    钱柜娱乐开户:layout_height="match_parent"
 />

item布局文件:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:钱柜娱乐开户="http://schemas.钱柜娱乐开户.com/apk/res/钱柜娱乐开户"
    钱柜娱乐开户:layout_width="match_parent"
    钱柜娱乐开户:layout_height="wrap_content"
    钱柜娱乐开户:background="@drawable/item_bg"
    钱柜娱乐开户:gravity="center">

    <com.imooc.rvimageads.AdImageViewVersion1
        钱柜娱乐开户:id="@+id/id_iv_ad"
        钱柜娱乐开户:layout_width="match_parent"
        钱柜娱乐开户:layout_height="180dp"
        钱柜娱乐开户:scaleType="matrix"
        钱柜娱乐开户:src="@mipmap/grsm"
        钱柜娱乐开户:visibility="gone" />

    <TextView
        钱柜娱乐开户:layout_margin="12dp"
        钱柜娱乐开户:id="@+id/id_tv_title"
        钱柜娱乐开户:layout_width="wrap_content"
        钱柜娱乐开户:layout_height="wrap_content"
        钱柜娱乐开户:text="这是title"
        钱柜娱乐开户:textSize="16dp"
        钱柜娱乐开户:textStyle="bold" />

    <TextView
        钱柜娱乐开户:id="@+id/id_tv_desc"
        钱柜娱乐开户:layout_width="wrap_content"
        钱柜娱乐开户:layout_height="wrap_content"
        钱柜娱乐开户:layout_below="@id/id_tv_title"
        钱柜娱乐开户:layout_marginLeft="12dp"
        钱柜娱乐开户:layout_marginRight="12dp"
        钱柜娱乐开户:layout_marginBottom="12dp"
        钱柜娱乐开户:text="这是描述" />

</RelativeLayout>

很简单,先不用管AdImageViewVersion1类,这将是我们具体的实现类。
通过布局文件,可以看到,我们只使用了一个item布局文件,然后通过visible,gone控制展示不同形态。

Activity


public class MainActivity extends AppCompatActivity {

    private RecyclerView mRecyclerView;
    private LinearLayoutManager mLinearLayoutManager;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mRecyclerView = findViewById(R.id.id_recyclerview);

        List<String> mockDatas = new ArrayList<>();
        for (int i = 0; i < 100; i++) {
            mockDatas.add(i + "");
        }

        mRecyclerView.setLayoutManager(mLinearLayoutManager = new LinearLayoutManager(this));

        mRecyclerView.setAdapter(new CommonAdapter<String>(MainActivity.this,
                R.layout.item,
                mockDatas) {
            @Override
            protected void convert(ViewHolder holder, String o, int position) {
                if (position > 0 && position % 6 == 0) {
                    holder.setVisible(R.id.id_tv_title, false);
                    holder.setVisible(R.id.id_tv_desc, false);
                    holder.setVisible(R.id.id_iv_ad, true);
                } else {
                    holder.setVisible(R.id.id_tv_title, true);
                    holder.setVisible(R.id.id_tv_desc, true);
                    holder.setVisible(R.id.id_iv_ad, false);
                }
            }
        });
}

仅仅是设置数据了,Adapter这里用了

compile 'com.zhy:base-rvadapter:3.0.3'

你可以随便用一个你自己喜欢的Adapter封装类。

到这里,一个列表页就显示出来了,并且每隔6个会显示成图片。

不截图了,脑补下…

现在才正式开始实现。

自定义AdImageView

public class AdImageViewVersion1 extends AppCompatImageView {
    public AdImageViewVersion1(Context context, @Nullable AttributeSet attrs) {
        super(context, attrs);
    }

    private RectF mBitmapRectF;
    private Bitmap mBitmap;

    private int mMinDy;

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);

        mMinDy = h;
        Drawable drawable = getDrawable();

        if (drawable == null) {
            return;
        }

        mBitmap = drawableToBitamp(drawable);
        mBitmapRectF = new RectF(0, 0,
                w,
                mBitmap.getHeight() * w / mBitmap.getWidth());

    }


    private Bitmap drawableToBitamp(Drawable drawable) {
        if (drawable instanceof BitmapDrawable) {
            BitmapDrawable bd = (BitmapDrawable) drawable;
            return bd.getBitmap();
        }
        int w = drawable.getIntrinsicWidth();
        int h = drawable.getIntrinsicHeight();
        Bitmap bitmap = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888);
        Canvas canvas = new Canvas(bitmap);
        drawable.setBounds(0, 0, w, h);
        drawable.draw(canvas);
        return bitmap;
    }

    // ... 省略一些代码
}

因为我们要绘制,所以这里我们把drawable转成bitmap,然后我们默认要显示最底部,所以需要一个最小的偏移,即控件高度。

这些事情,我们都在onSizeChanged做了。

并且我们根据当前控件宽度,对bitmap进行了缩放,并将缩放后的尺寸存在了mBitmapRectF中,以便于绘制。

那么接下来就是绘制了,还记得绘制过程中,我们主要利用translate来控制绘制的区域,所以我们还要对外暴露一个setDy方法,so,我们的代码大致是这样的:

private int mDy;

public void setDy(int dy) {

    if (getDrawable() == null) {
        return;
    }
    mDy = dy - mMinDy;
    if (mDy <= 0) {
        mDy = 0;
    }
    if (mDy > mBitmapRectF.height() - mMinDy) {
        mDy = (int) (mBitmapRectF.height() - mMinDy);
    }
    invalidate();
}

@Override
protected void onDraw(Canvas canvas) {
    if (mBitmap == null) {
        return;
    }
    canvas.save();
    canvas.translate(0, -mDy);
    canvas.drawBitmap(mBitmap, null, mBitmapRectF, null);
    canvas.restore();
}

setDy的时候,我们做了一个边界判断,最小的情况,我们偏移-mMinDy,显示图片的底部。
最大的时候,我们便宜图片高度-mMinDy,显示顶部部分。

所以我们对传入的值做了最小与最大值判断。

那么在绘制的时候,就简单了,先translate dy距离,然后绘制即可。

到这里我们的自定义View部分就结束了,代码很少~

结合RecyclerView

接下来就是在RecyclerView滚动时,给我们传入dy即可。

mRecyclerView.addOnScrollListener(new RecyclerView.OnScrollListener() {
    @Override
    public void onScrolled(RecyclerView recyclerView, int dx, int dy) {
        super.onScrolled(recyclerView, dx, dy);

        int fPos = mLinearLayoutManager.findFirstVisibleItemPosition();
        int lPos = mLinearLayoutManager.findLastCompletelyVisibleItemPosition();
        for (int i = fPos; i <= lPos; i++) {
            View view = mLinearLayoutManager.findViewByPosition(i);
            AdImageViewVersion1 adImageView = view.findViewById(R.id.id_iv_ad);
            if (adImageView.getVisibility() == View.VISIBLE) {
                adImageView.setDy(mLinearLayoutManager.getHeight() - view.getTop());
            }
        }
    }
});

通过addOnScrollListener监听,当滚动时,拿到所有可见的Item,找出正在显示图片的Item。然后调用setDy,dy的值为mLinearLayoutManager.getHeight() - view.getTop(),当View从最底部出现的时候为0,当View到达最顶部的时候为当前rv的高度。

你可以合理的利用setDy传入的值,做移动差,显示区域从上到下等,都可以。

这样就完成了~~

一句话实现:即滚动时不断改变dy,然后translate绘制即可。

四、再想想

看着这个代码,好像drawableToBitamp看起来非常不爽,也是比较耗内存的部分。我们再想想:

本身Drawable就是能绘制的,为什么我们要转成bitmap呢?

好像有道理,ImageView本身绘制的就是Drawable,我们需要控制的就是这个Drawable的绘制范围要足够大,不能被控件本身的宽高所影响,导致图片被压扁。

好像有那么一个方法:

drawable.setBounds();

那就简单了,去除drawable2bitmap的代码,直接利用原本的绘制即可,我们唯一要做的就是设置bounds,做一个translate dy即可。

完整代码:

public class AdImageView extends AppCompatImageView {
    // 删除构造方法

    private int mDx;
    private int mMinDx;

    public void setDx(int dx) {
        if (getDrawable() == null) {
            return;
        }
        mDx = dx - mMinDx;
        if (mDx <= 0) {
            mDx = 0;
        }
        if (mDx > getDrawable().getBounds().height() - mMinDx) {
            mDx = getDrawable().getBounds().height() - mMinDx;
        }
        invalidate();
    }

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        super.onSizeChanged(w, h, oldw, oldh);
        mMinDx = h;
    }

    public int getDx() {
        return mDx;
    }

    @Override
    protected void onDraw(Canvas canvas) {

        Drawable drawable = getDrawable();
        int w = getWidth();
        int h = (int) (getWidth() * 1.0f / drawable.getIntrinsicWidth() * drawable.getIntrinsicHeight());
        drawable.setBounds(0, 0, w, h);
        canvas.save();
        canvas.translate(0, -getDx());
        super.onDraw(canvas);
        canvas.restore();
    }
}

短短的代码就实现了,这样看起来顺眼多了~~

再贴下效果图:

效果图主要看字,你懂的!

好了,本篇总结:

看到当看一个效果,可以先对它进行拆分,找出关键点,针对每个关键点,考虑可行性。

如果确定每个点都可行,那么基本的方案就出来了。

有了基本的方案,不要着急写,再想想还有无改善空间。

例子比较简单,have a nice day ~~

https://github.com/hongyang钱柜娱乐开户/demo_rvadimage

作者:lmj623565791 发表于2017/12/5 9:45:02 原文链接
阅读:1918 评论:7 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/78011599 /lmj623565791/article/details/78011599 lmj623565791 2017/9/17 17:06:43

本文已在我的公众号hongyang钱柜娱乐开户原创首发。
转载请标明出处:
/lmj623565791/article/details/78011599
本文出自张鸿洋的博客

本文已在我的公众号hongyang钱柜娱乐开户原创首发,文章合集

一、概述

ConstraintLayout出现有一段时间了,不过一直没有特别去关注,也多多少少看了一些文字介绍,多数都是对使用可视化布局拖拽,个人对拖拽一直不看好,直到前段时间看到该文:

非常详尽的介绍了ConstraintLayout的性能优势,于是乎开始学习了一下ConstraintLayout。

本文的重点不在与可视化界面的学习,而在于如何手写各类约束布局属性。对于可视化界面学习推荐:

下面开始进入正题,大家都知道,当布局嵌套深入比较深的时候,往往会伴随着一些性能问题。所以很多时候我们建议使用RelativeLayout或者GridLayout来简化掉布局的深度。

而对于简化布局深度,ConstraintLayout几乎可以做到极致,接下来我们通过实例来尽可能将所有常见的属性一步步的介绍清楚。

首先需要引入我们的ConstraintLayout,在build.gradle中加入:

compile 'com.钱柜娱乐开户.support.constraint:constraint-layout:1.0.2'

二、来编写一个Feed Item

我们先看一个简单的新闻列表中常见的feed item。

看到这样的布局,大家条件反射应该就是使用RelativeLayout来做,当然了,本案例我们使用ConstraintLayout来写:

<钱柜娱乐开户.support.constraint.ConstraintLayout 
    xmlns:钱柜娱乐开户="http://schemas.钱柜娱乐开户.com/apk/res/钱柜娱乐开户"
    xmlns:app="http://schemas.钱柜娱乐开户.com/apk/res-auto"
    xmlns:tools="http://schemas.钱柜娱乐开户.com/tools"
    钱柜娱乐开户:id="@+id/activity_main"
    钱柜娱乐开户:layout_width="match_parent"
    钱柜娱乐开户:layout_height="match_parent"
    钱柜娱乐开户:background="#11ff0000"
    tools:context="com.zhy.constrantlayout_learn.MainActivity">


    <TextView
        钱柜娱乐开户:id="@+id/tv1"
        钱柜娱乐开户:layout_width="140dp"
        钱柜娱乐开户:layout_height="86dp"
        钱柜娱乐开户:layout_marginLeft="12dp"
        钱柜娱乐开户:layout_marginTop="12dp"
        钱柜娱乐开户:background="#fd3"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <TextView
        钱柜娱乐开户:id="@+id/tv2"
        钱柜娱乐开户:layout_width="0dp"
        钱柜娱乐开户:layout_height="wrap_content"
        钱柜娱乐开户:layout_marginLeft="8dp"
        钱柜娱乐开户:layout_marginRight="12dp"
        钱柜娱乐开户:text="马云:一年交税170多亿马云:一年交税170多亿马云:一年交税170多亿"
        钱柜娱乐开户:textColor="#000000"
        钱柜娱乐开户:textSize="16dp"
        app:layout_constraintLeft_toRightOf="@id/tv1"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="@id/tv1" />

    <TextView
        钱柜娱乐开户:layout_width="wrap_content"
        钱柜娱乐开户:layout_height="wrap_content"
        钱柜娱乐开户:layout_marginLeft="8dp"
        钱柜娱乐开户:layout_marginTop="12dp"
        钱柜娱乐开户:text="8分钟前"
        钱柜娱乐开户:textColor="#333"
        钱柜娱乐开户:textSize="12dp"
        app:layout_constraintLeft_toRightOf="@id/tv1"
        app:layout_constraintBottom_toBottomOf="@id/tv1" />

</钱柜娱乐开户.support.constraint.ConstraintLayout>

看上面的布局,我们好像看到了几个模式的属性:

首先是tv1,有两个没见过的属性:

  • app:layout_constraintLeft_toLeftOf="parent"

从字面上看,指的是让该控件的左侧与父布局对齐,当我们希望控件A与控件B左侧对齐时,就可以使用该属性。

app:layout_constraintLeft_toLeftOf="@id/viewB"

类似的还有个相似的属性为:

  • app:layout_constraintLeft_toRightOf

很好理解,即当前属性的左侧在谁的右侧,当我们希望控件A在控件B的右侧时,可以设置:

app:layout_constraintLeft_toRightOf="@id/viewB"

与之类似的还有几个属性:

  • layout_constraintRight_toLeftOf
  • layout_constraintRight_toRightOf
  • layout_constraintTop_toTopOf
  • layout_constraintTop_toBottomOf
  • layout_constraintBottom_toTopOf
  • layout_constraintBottom_toBottomOf
  • layout_constraintBaseline_toBaselineOf

类推就可以了。

现在在头看刚才的布局:

tv1设置了:

app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintTop_toTopOf="parent"

tv2设置了:

app:layout_constraintLeft_toRightOf="@id/tv1"
app:layout_constraintRight_toRightOf="parent"
app:layout_constraintTop_toTopOf="@id/tv1"

tv3设置了:

app:layout_constraintLeft_toRightOf="@id/tv1"
app:layout_constraintBottom_toBottomOf="@id/tv1"

按照我们刚才的理解,再次的解读下:

tv1应该是在父布局的左上角;

tv2在tv1的右侧,tv2的右侧和父布局对其,tv2和tv1顶部对齐;

tv3在tv1的右侧,tv3和tv1底部对其。

到这里,大家可以看到,目前我们已经可以控制任何一个控件与其他控件间的相对位置了,以及与parent间的相对位置。

和RL的差异

大家是不是觉得目前来看和RelativeLayout特别像?

其实还是有很明显的区别的,我们通过一个例子来看一下:

<RelativeLayout xmlns:钱柜娱乐开户="http://schemas.钱柜娱乐开户.com/apk/res/钱柜娱乐开户"
    钱柜娱乐开户:layout_width="match_parent" 钱柜娱乐开户:layout_height="match_parent">

    <Button
        钱柜娱乐开户:id="@+id/id_btn01"
        钱柜娱乐开户:layout_width="100dp"
        钱柜娱乐开户:text="Btn01"
        钱柜娱乐开户:layout_height="wrap_content" />

    <Button
        钱柜娱乐开户:layout_width="wrap_content"
        钱柜娱乐开户:layout_height="wrap_content"
        钱柜娱乐开户:layout_toRightOf="@id/id_btn01"
        钱柜娱乐开户:text="Btn02"
        钱柜娱乐开户:layout_alignParentRight="true"
        />

</RelativeLayout>

那么经过我们刚才的学习,把:

layout_toRightOf="@id/id_btn01"layout_alignParentRight="true"

分别替换为:

app:layout_constraintLeft_toRightOf="@id/id_btn01"app:layout_constraintRight_toRightOf="parent"

是不是觉得so easy ,但是我们看一下效果图:

是不是和预期有一定的区别,假设你将Btn02的宽度设置的非常大,你会发现更加诡异的事情:

你会发现Btn02,好像疯了一样,我们设置的在btn01右侧,和与parent右侧对齐完全失效了!!!

别怕,接下来就让你认识到为什么这个控件叫做“Constraint”Layout。

在当控件有自己设置的宽度,例如warp_content、固定值时,我们为控件添加的都是约束“Constraint”,这个约束有点像橡皮筋一样会拉这个控件,但是并不会改变控件的尺寸(RL很明显不是这样的)。

例如上例,当btn02的宽度较小时,我们为其左侧设置了一个约束(btn01右侧),右侧设置了一个约束(parent右侧对其),当两个约束同时生效的时候(你可以认为两边都是相同的一个拉力),btn02会居中。

当btn02特别大的时候,依然是这两个力,那么会发生什么?会造成左侧和右侧超出的距离一样大。

那么现在大家肯定有些疑问:

  • 怎么样才能和上面的RL一样,宽度刚好占据剩下的距离呢(btn01右侧到屏幕右侧的距离)?

这个问题,问得很好,我们刚才所有的尝试都是在控件自身拥有特定的宽度情况下执行的;那么如果希望控件的宽度根据由约束来控件,不妨去掉这个特定的宽度,即设置为0试试?

对!当我们将btn02的宽度设置为0时,一切又变得很完美。

那么这里,你可能会问0值是什么含义,其实在ConstraintLayout中0代表:MATCH_CONSTRAINT,看到这个常量,是不是瞬间觉得好理解了一点。

  • 最后一个问题,MATCH_PARENT哪去了?

看官网的解释:

Important: MATCH_PARENT is not supported for widgets contained in a ConstraintLayout, though similar behavior can be defined by using MATCH_CONSTRAINT with the corresponding left/right or top/bottom constraints being set to “parent”.`

所以你可以认为:在ConstraintLayout中已经不支持MATCH_PARENT这个值了,你可以通过MATCH_CONSTRAINT配合约束实现类似的效果。

好了,到这里,目前我们已经看到其已经和RelativeLayout势均力敌了,接下来我们看一下RL做不到的特性。

三、增加一个banner

我们现在以往在这个feed item顶部添加一个banner,宽度为占据整个屏幕,宽高比为16:6。

这里尴尬了,在之前的做法,很难在布局中设置宽高比,一般我们都需要在代码中显示的去操作,那么如果你用了ConstraintLayout,它就支持。

看一眼如何支持:

<钱柜娱乐开户.support.constraint.ConstraintLayout 
    ...
    tools:context="com.zhy.constrantlayout_learn.MainActivity">

    <TextView
        钱柜娱乐开户:id="@+id/banner"
        钱柜娱乐开户:layout_width="0dp"
        钱柜娱乐开户:layout_height="0dp"
        钱柜娱乐开户:background="#765"
        钱柜娱乐开户:gravity="center"
        钱柜娱乐开户:text="Banner"
        app:layout_constraintDimensionRatio="H,16:6"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent" />


    <TextView
        钱柜娱乐开户:id="@+id/tv1"
        app:layout_constraintTop_toBottomOf="@id/banner"
        ></TextView>
     ...
</...>

我们添加了一个banner,还记得我们刚才所说的么,不要使用match_parent了,而是设置match_contraint,即0,让约束来控制布局宽高。

所以我们设置了宽、高都是match_contraint,然后这两个属性:

app:layout_constraintLeft_toLeftOf="parent"
app:layout_constraintRight_toRightOf="parent"

让我们的宽度充满整个父布局,在添加一个:

app:layout_constraintDimensionRatio="16:6"

该属性指的是宽高比,所以16:6就可以完成我们的需求。

好了看下效果图:

这个宽高比属性,还支持这样的写法:

app:layout_constraintDimensionRatio="W,16:6"
app:layout_constraintDimensionRatio="H,16:6"

可以自己试验下。

好了,到这里,我们又新增了一个属性,还是个非常实用的属性。

那么,我们继续,再看一个似曾相识的功能。

四、增加几个Tab

现在我们希望在底部增加3个tab,均分。是不是想到了LinearLayout和weight。

没错!ConstraintLayout也支持类似的属性。

虽然我知道,但是写到这我还是有点小惊喜~~

看下如何实现:

<TextView
    钱柜娱乐开户:id="@+id/tab1"
    钱柜娱乐开户:layout_width="0dp"
    钱柜娱乐开户:layout_height="30dp"
    钱柜娱乐开户:background="#f67"
    钱柜娱乐开户:gravity="center"
    钱柜娱乐开户:text="Tab1"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toLeftOf="parent"
    app:layout_constraintRight_toLeftOf="@+id/tab2" />


<TextView
    钱柜娱乐开户:id="@+id/tab2"
    钱柜娱乐开户:layout_width="0dp"
    钱柜娱乐开户:layout_height="30dp"
    钱柜娱乐开户:background="#A67"
    钱柜娱乐开户:gravity="center"
    钱柜娱乐开户:text="Tab2"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toRightOf="@id/tab1"
    app:layout_constraintRight_toLeftOf="@+id/tab3" />


<TextView
    钱柜娱乐开户:id="@+id/tab3"
    钱柜娱乐开户:layout_width="0dp"
    钱柜娱乐开户:layout_height="30dp"
    钱柜娱乐开户:background="#767"
    钱柜娱乐开户:gravity="center"
    钱柜娱乐开户:text="Tab3"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintLeft_toRightOf="@id/tab2"
    app:layout_constraintRight_toRightOf="parent" />

我们增加3个textview来冒充tab。我们看横向的依赖,3个tab两两设置了约束(即你在我们的左边,我在你的右边),最外层的设置了parent约束;再加上我们把宽度都设置为了match_constraint,so,这样我们就完成了3个tab等分。

看一眼效果图:

你可能会说,LL配合weight更加灵活,可以单个设置占据的比例。

对,没错,我们也支持,我不是还没说完么。

现在我们可以给每个tab设置一个属性:

app:layout_constraintHorizontal_weight

看到这个名字,应该就明白了吧,假设我们分别设置值为2,1,1。

效果图为:

是不是很惊喜,别急,刚才你说我不如LL,现在我要让你再看一些LL配合weight做不到的。

这里需要借助几张官网上的图了:

刚才我们说了,3个tab两两设置了依赖,即类似下图:

横向的相当于组成了一个链(Chains)。在这个链的最左侧的元素成为链头,我们可以在其身上设置一些属性,来决定这个链的展示效果:

该属性为:

layout_constraintHorizontal_chainStyle

我们已经见过一种效果了,即按照weight等分,可以成为weighted chain。设置条件为:

chainStyle=”spread”,所有控件宽度设置为match_constraint,因为默认就是spread,所以我们没有显示设置。

其取值还可以为:

  • packed
  • spread_inside

我还是分别显示一下吧:

  1. spread + 宽度非0

  1. spread + 宽度为0,且可以通过weight控制分配比例(上例)

  2. spread_inside + 宽度非0

  1. packed + 宽度非0

好了,差不多了,我们可以在横向或者纵向组成一个Chain,然后在Chain head设置chainStyle来搞一些事情。

官网有个图:

前四个我们都演示了,最后一个设计到一个新的bias属性,别急,咱们慢慢说~~

好了,到这里,我们再次见证了ConstraintLayout的强大。

我们最后再看一个例子。

五、增加浮动按钮

一个很常见的功能,我们现在希望在右下角增加一个浮动按钮。

看下如何实现:

<钱柜娱乐开户.support.constraint.ConstraintLayout 
    ...
    tools:context="com.zhy.constrantlayout_learn.MainActivity">

    <TextView
        钱柜娱乐开户:layout_width="60dp"
        钱柜娱乐开户:layout_height="60dp"
        钱柜娱乐开户:background="#612"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintHorizontal_bias="0.9"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintVertical_bias="0.9" />

</....>

我们在最后追加一个TextView冒充我们的浮动按钮。可以看到我们设置了固定值,被设置约束为右下角。

正常情况我们可以通过margin来设置与右侧与底部的距离。

但是这里我们尝试使用量个新的属性:

layout_constraintHorizontal_bias
layout_constraintVertical_bias

即设置上下两侧间隙比例分别为90%与10%。这个很好理解,我们之前说了,再没有bias这个属性的时候,这两侧的拉力大小是一样的,但是你可以通过bias来控制哪一侧的力要大一些~~明白了么~

所以,该属性可以用于约束之前,控制两侧的“拉力”。

我们看一下效果图:

那么到这里,ConstraintLayout的属性我们基本上介绍完了:

我们看一下:

layout_constraintLeft_toLeftOf 
layout_constraintLeft_toRightOf
layout_constraintRight_toLeftOf
layout_constraintRight_toRightOf
layout_constraintTop_toTopOf
layout_constraintTop_toBottomOf
layout_constraintBottom_toTopOf
layout_constraintBottom_toBottomOf

# 即文章的baseline对齐
layout_constraintBaseline_toBaselineOf

# 与left,right类似
layout_constraintStart_toEndOf 
layout_constraintStart_toStartOf
layout_constraintEnd_toStartOf
layout_constraintEnd_toEndOf

# margin不需要解释
钱柜娱乐开户:layout_marginStart
钱柜娱乐开户:layout_marginEnd
钱柜娱乐开户:layout_marginLeft
钱柜娱乐开户:layout_marginTop
钱柜娱乐开户:layout_marginRight
钱柜娱乐开户:layout_marginBottom

layout_constraintHorizontal_bias  
layout_constraintVertical_bias  

layout_constraintHorizontal_chainStyle
layout_constraintVertical_chainStyle

layout_constraintVertical_weight

Guideline 

好像,还有个比较特殊的,叫Guideline。

好吧,继续~

六、尝试使用Guideline

钱柜娱乐开户.support.constraint.Guideline该类比较简单,主要用于辅助布局,即类似为辅助线,横向的、纵向的。该布局是不会显示到界面上的。

所以其有个属性为:

钱柜娱乐开户:orientation取值为”vertical”和”horizontal”.

除此以外,还差个属性,决定该辅助线的位置:

  • layout_constraintGuide_begin
  • layout_constraintGuide_end
  • layout_constraintGuide_percent

可以通过上面3个属性其中之一来确定属性值位置。

begin=30dp,即可认为距离顶部30dp的地方有个辅助线,根据orientation来决定是横向还是纵向。

end=30dp,即为距离底部。
percent=0.8即为距离顶部80%。

好了,下面看一个例子,刚才我们的浮点按钮,我决定通过两根辅助线来定位,一根横向距离底部80%,一个纵向距离顶部80%,浮点按钮就定位在他们交叉的地方。

<钱柜娱乐开户.support.constraint.ConstraintLayout 
    ...
    tools:context="com.zhy.constrantlayout_learn.MainActivity">

    <钱柜娱乐开户.support.constraint.Guideline
        钱柜娱乐开户:id="@+id/guideline_h"
        钱柜娱乐开户:layout_width="wrap_content"
        钱柜娱乐开户:layout_height="wrap_content"
        钱柜娱乐开户:orientation="horizontal"
        app:layout_constraintGuide_percent="0.8" />


    <钱柜娱乐开户.support.constraint.Guideline
        钱柜娱乐开户:id="@+id/guideline_w"
        钱柜娱乐开户:layout_width="wrap_content"
        钱柜娱乐开户:layout_height="wrap_content"
        钱柜娱乐开户:orientation="vertical"
        app:layout_constraintGuide_percent="0.8" />

    <TextView
        钱柜娱乐开户:layout_width="60dp"
        钱柜娱乐开户:layout_height="60dp"
        钱柜娱乐开户:background="#612"
        app:layout_constraintLeft_toRightOf="@id/guideline_w"
        app:layout_constraintTop_toBottomOf="@id/guideline_h" />

</....>

我感觉都不用解释了~~看眼效果图吧:

到此,属性基本上讲完啦~

可以看到,上述相当复杂的一个布局,在ConstraintLayout中完全没有嵌套!

六、总结

本文通过实际的按钮,基本上介绍了ConstraintLayout所支持的所有的属性,全文没有提及拖拽,因为当界面复杂之后,想要完美的拖拽实在是太难了,而且谁也不期望,看不懂拖拽完成后的布局属性吧~

所以,我建议还是尽可能手写,通过本文这样一个流程,虽然支持的属性有20多个,但是分类后并不难记,难记也可以拿出本文翻一翻~

好了,思考了半天,如何通过一个案例介绍完所有的属性,总体来说还是完成了,给自己点个赞。


支持我的话可以关注下我的公众号,每天都会推送新知识~

欢迎关注我的微信公众号:hongyang钱柜娱乐开户
(可以给我留言你想学习的文章,支持投稿)

参考

作者:lmj623565791 发表于2017/9/17 17:06:43 原文链接
阅读:19005 评论:43 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/77937483 /lmj623565791/article/details/77937483 lmj623565791 2017/9/12 7:53:43

本文已在我的公众号hongyang钱柜娱乐开户原创首发。
转载请标明出处:
/lmj623565791/article/details/77937483
本文出自张鸿洋的博客

本文已在我的公众号hongyang钱柜娱乐开户原创首发,文章合集

公众号后台很多关注者给我留言,想学习直播相关技术,但是无从下手,其实我也非直播专业人士,不过可以提供点入门的方案,希望以此做到一定的引导作用。

首先搜索了一波,发现了知乎上还有个类似的提问:

https://www.zhihu.com/question/49160322/answer/114587604

文章中第一个回答就是指导如何搭建一个直播系统。

从0开始搭建一个直播系统

我立马实践了下,所以首先给大家分享下整个搭建的流程:

本人的操作系统为mac,其他系统的同学可以根据提示,自行安装软件。

一个简易的直播系统,大致可以由三部分组成:

  • 搭建一个rtmp媒体服务器
  • 推流端
  • 拉流端

现在目标是快速搭建起来,所以当然是借助开源项目和一些软件:

  • rtmp媒体服务器:这里使用srs
  • 推流端:这里使用obs
  • 拉流端:这里使用播放器vlc

rtmp媒体服务器的搭建

这里使用srs,srs的链接为:
https://github.com/ossrs/srs

首先clone到本地,进入到trunk目录:

git clone https://github.com/ossrs/srs.git
cd srs/trunk

然后执行:

./configure --osx

注意: Centos6.x/Ubuntu12 32/64bits用户仅需要执行./configure。

最后执行:

make

执行成功后,就可以开启我们的服务了:

./etc/init.d/srs start

如果是mac系统,此时会失败,原因是srs.conf中max_connections太大,
目录为srs/trunk/conf/srs.conf,可以修改为248(其他操作系统可能无此问题)。

再次回到trunk目录:

./etc/init.d/srs start

到此我们的srs服务器就搭建起来了。

注:
Centos、Ubuntu可以参考官网搭建,比较简单。
如果你启动过程中还遇到了其他错误,可以查看log信息:
srs/trunk/objs/srs.log
其他指令:
停止 ./etc/init.d/srs stop
重启 ./etc/init.d/srs restart

有了服务器之后,我们就准备开始我们的推流端。

如果你实在搭建不成功,可以先拿116.196.121.20这个ip做测试,我在京东云上搭建的,配置较低,主要用于大家临时测试,可能不稳定,看一眼就行,后续会关掉,所以还是尽可能自己搭建成功吧。

使用OBS推流

下载地址:https://obsproject.com/

先下载安装,这里就简单了

首先选择点击+选择来源,这里我选择了窗口捕获,然后点击右侧的设置:

选择流,串流类型选择自定义,然后url,填写:

rtmp://你的ip/你喜欢的url

流名称可以按照上述自由输入。

记住我们的url和流名称:

rtmp://192.168.1.102/zhy/mylive

完成后,点击确定。

然后点击开始推流即可。

这样,我们的OBS推流就开启啦,软件的更多使用自行探索吧。

最后就剩下我们的拉流了。

使用VLC拉流

下载地址:http://www.videolan.org/vlc/

先下载安装,这个就更简单啦。

点击Open Network,输入我们刚才的url+流名称,点击确定即可。

稍等,就开始播放我们的推流内容了。

演示个动图:

最左侧是VLC,中间是OBS,右侧是我窗口捕获对象。

到这里,就算我们搭建了一个直播系统啦~~自己搭建成功的感觉,无比爽快,也能很大的激发我们后续的学习兴趣。

后面我们可以根据自己的需求去选择学习拉流或者推流,逐步替换掉软件。拉流的方式很多,很多开源播放器都支持。这里我们考虑替换掉推流,希望可以使用手机来推流。

使用第三方推流SDK

最简单的方式,就是可以借助于第三方的推流SDK,大多情况下第三方SDK完整方案都是收费的,不过他们的推流钱柜娱乐开户 SDK倒是可以下载无需付费情况下来学习使用的。

这里以百度云的直播SDK为例,下载地址:
https://cloud.baidu.com/doc/Downloadcenter/Push.html#.E7.89.88.E6.9C.AC.E6.9B.B4.E6.96.B0.E8.AF.B4.E6.98.8E

直接点击下载钱柜娱乐开户 SDK即可,下载完成后解压,然后倒入AS(这竟然是个Eclipse项目…),还好AS支持,导入后:

直接将mStreamKey修改我们的rtmp的地址即可。

注意,需要在build.gradle中添加下v7的依赖

compile 'com.钱柜娱乐开户.support:appcompat-v7:23.0.0'

然后运行,界面还可以:

贴一下运行时的效果图:

还是以vlc拉流即可,整个过程很缓慢,耐心等待,效果也不是很好,不过能跑通即可,主要是学习。然后你可以举一反三试试其他的SDK。

当然了,很多开源项目其实比SDK作为学习资料更好,比较源码都有,下面以一个开源项目举例。

使用开源项目推流

使用一个开源项目:

https://github.com/begeekmyfriend/yasea

直接clone,导入。
这个比较顺利,导入后,修改下rtmp链接:

然后运行即可(导入不成功,自己想办法解决,基础能力啦~)。

贴一张效果图:

硬解码的情况下,效果比前面的SDK好很多~~

好了,最后我们再看一种方式。

恩,ffmpeg很火,ffmpeg很强大。
所以最后一种方式,就是看如何利用ffmpeg推流啦~~

利用ffmpeg推流

大家可以自己下载ffmepg的源码,然后按照网上的方式去编成so,简单的一点而且比较实用的,就是编出可以执行ffmpeg 命令的so,这样就能干很多事情了。

这里,由于篇幅,我们就直接使用别人编好的项目了。

https://github.com/WritingMinds/ffmpeg-钱柜娱乐开户-java

直接导入,该项目支持直接运行ffmpeg的命令。

ffmpeg命令很多:
例如:

将.avi转成gif动画(未压缩)
ffmpeg -i video_origine.avi gif_anime.gif
合成视频和音频
ffmpeg -i son.wav -i video_origine.avi video_finale.mpg
还有非常多的功能,可以参考:
/king1425/article/details/70348374

其中有一个命令就是支持推流,这里将手机上的zixia.mp4作为输入:

ffmpeg -re -i /storage/emulated/0/zixia.mp4 
    -vcodec libx264 
    -acodec aac 
    -f flv 
    -strict -2 rtmp://192.168.1.102/zhy/mylive=

那么这个库是支持在手机上运行ffmpeg命令的,那就简单了:

贴上我们需要执行的命令,运行即可。

这里注意我推的是存储卡上的一个媒体文件,注意添加相关权限,效果如下。

好了,这样我们的大致学习了如何搭建一个小直播系统,如何利用SDK,开源项目,以及简单的使用ffmpeg来进行推流~~

很多时候,学习一个比较大的技术方向就是开头难,无从下手,那么本篇应该是一篇非常易懂的教程,希望对想要学习直播技术的小伙伴有所帮助,也希望以此能够激发大家一定的学习兴趣,当然直播技术远不止于此,大家可以根据自己的情况继续深入学习~


支持我的话可以关注下我的公众号,每天都会推送新知识~

欢迎关注我的微信公众号:hongyang钱柜娱乐开户
(可以给我留言你想学习的文章,支持投稿)

参考:

https://github.com/WritingMinds/ffmpeg-钱柜娱乐开户-java
https://github.com/begeekmyfriend/yasea
https://cloud.baidu.com/doc/Downloadcenter/Push.html
https://www.zhihu.com/question/49160322/answer/114587604
https://github.com/ossrs/srs
http://www.jianshu.com/p/dd3f58392aa0#
/king1425/article/details/70348374

作者:lmj623565791 发表于2017/9/12 7:53:43 原文链接
阅读:11081 评论:22 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/75000580 /lmj623565791/article/details/75000580 lmj623565791 2017/7/12 0:03:04

本文已在我的公众号hongyang钱柜娱乐开户原创首发。
转载请标明出处:
/lmj623565791/article/details/75000580
本文出自张鸿洋的博客

本文已在我的公众号hongyang钱柜娱乐开户原创首发,文章合集

一、概述

之前一直没有写过插件化相关的博客,刚好最近滴滴和360分别开源了自家的插件化方案,赶紧学习下,写两篇博客,第一篇是滴滴的方案:

那么其中的难点很明显是对四大组件支持,因为大家都清楚,四大组件都是需要在钱柜娱乐开户Manifest中注册的,而插件apk中的组件是不可能预先知晓名字,提前注册中宿主apk中的,所以现在基本都采用一些hack方案类解决,VirtualAPK大体方案如下:

  • Activity:在宿主apk中提前占几个坑,然后通过“欺上瞒下”(这个词好像是360之前的ppt中提到)的方式,启动插件apk的Activity;因为要支持不同的launchMode以及一些特殊的属性,需要占多个坑。
  • Service:通过代理Service的方式去分发;主进程和其他进程,VirtualAPK使用了两个代理Service。
  • BroadcastReceiver:静态转动态
  • ContentProvider:通过一个代理Provider进行分发。

这些占坑的数量并不是固定的,比如Activity想支持某个属性,该属性不能动态设置,只能在Manifest中设置,那就需要去占坑支持。所以占坑数量这些,可以根据自己的需求进行调整。

下面就逐一去分析代码啦~

注:本篇博客涉及到的framework逻辑,为API 22.

分期版本为 com.didi.virtualapk:core:0.9.0

二、Activity的支持

这里就不按照某个流程一行行代码往下读了,针对性的讲一些关键流程,可能更好阅读一些。

首先看一段启动插件Activity的代码:

final String pkg = "com.didi.virtualapk.demo";
if (PluginManager.getInstance(this).getLoadedPlugin(pkg) == null) {
    Toast.makeText(this, "plugin [com.didi.virtualapk.demo] not loaded", Toast.LENGTH_SHORT).show();
    return;
}

// test Activity and Service
Intent intent = new Intent();
intent.setClassName(pkg, "com.didi.virtualapk.demo.aidl.BookManagerActivity");
startActivity(intent);

可以看到优先根据包名判断该插件是否已经加载,所以在插件使用前其实还需要调用

pluginManager.loadPlugin(apk);

加载插件。

这里就不赘述源码了,大致为调用PackageParser.parsePackage解析apk,获得该apk对应的PackageInfo,资源相关(AssetManager,Resources),DexClassLoader(加载类),四大组件相关集合(mActivityInfos,mServiceInfos,mReceiverInfos,mProviderInfos),针对Plugin的PluginContext等一堆信息,封装为LoadedPlugin对象。

详细可以参考com.didi.virtualapk.internal.LoadedPlugin类。

ok,如果该插件以及加载过,则直接通过startActivity去启动插件中目标Activity。

(1)替换Activity

这里大家肯定会有疑惑,该Activity必然没有在Manifest中注册,这么启动不会报错吗?

正常肯定会报错呀,所以我们看看它是怎么做的吧。

跟进startActivity的调用流程,会发现其最终会进入Instrumentation的execStartActivity方法,然后再通过ActivityManagerProxy与AMS进行交互。

而Activity是否存在的校验是发生在AMS端,所以我们在于AMS交互前,提前将Activity的ComponentName进行替换为占坑的名字不就好了么?

这里可以选择hook Instrumentation,或者ActivityManagerProxy都可以达到目标,VirtualAPK选择了hook Instrumentation.

打开PluginManager可以看到如下方法:

private void hookInstrumentationAndHandler() {
    try {
        Instrumentation baseInstrumentation = ReflectUtil.getInstrumentation(this.mContext);
        if (baseInstrumentation.getClass().getName().contains("lbe")) {
            // reject executing in paralell space, for example, lbe.
            System.exit(0);
        }

        final VAInstrumentation instrumentation = new VAInstrumentation(this, baseInstrumentation);
        Object activityThread = ReflectUtil.getActivityThread(this.mContext);
        ReflectUtil.setInstrumentation(activityThread, instrumentation);
        ReflectUtil.setHandlerCallback(this.mContext, instrumentation);
        this.mInstrumentation = instrumentation;
    } catch (Exception e) {
        e.printStackTrace();
    }
}

可以看到首先通过反射拿到了原本的Instrumentation对象,拿的过程是首先拿到ActivityThread,由于ActivityThread可以通过静态变量sCurrentActivityThread或者静态方法currentActivityThread()获取,所以拿到其对象相当轻松。拿到ActivityThread对象后,调用其getInstrumentation()方法,即可获取当前的Instrumentation对象。

然后自己创建了一个VAInstrumentation对象,接下来就直接反射将VAInstrumentation对象设置给ActivityThread对象即可。

这样就完成了hook Instrumentation,之后调用Instrumentation的任何方法,都可以在VAInstrumentation进行拦截并做一些修改。

这里还hook了ActivityThread的mH类的Callback,暂不赘述。

刚才说了,可以通过Instrumentation的execStartActivity方法进行偷梁换柱,所以我们直接看对应的方法:

public ActivityResult execStartActivity(
        Context who, IBinder contextThread, IBinder token, Activity target,
        Intent intent, int requestCode, Bundle options) {
    mPluginManager.getComponentsHandler().transformIntentToExplicitAsNeeded(intent);
    // null component is an implicitly intent
    if (intent.getComponent() != null) {
        Log.i(TAG, String.format("execStartActivity[%s : %s]", intent.getComponent().getPackageName(),
                intent.getComponent().getClassName()));
        // resolve intent with Stub Activity if needed
        this.mPluginManager.getComponentsHandler().markIntentIfNeeded(intent);
    }

    ActivityResult result = realExecStartActivity(who, contextThread, token, target,
                intent, requestCode, options);

    return result;

}

首先调用transformIntentToExplicitAsNeeded,这个主要是当component为null时,根据启动Activity时,配置的action,data,category等去已加载的plugin中匹配到确定的Activity的。

本例我们的写法ComponentName肯定不为null,所以直接看markIntentIfNeeded()方法:

public void markIntentIfNeeded(Intent intent) {
    if (intent.getComponent() == null) {
        return;
    }

    String targetPackageName = intent.getComponent().getPackageName();
    String targetClassName = intent.getComponent().getClassName();
    // search map and return specific launchmode stub activity
    if (!targetPackageName.equals(mContext.getPackageName())
            && mPluginManager.getLoadedPlugin(targetPackageName) != null) {
        intent.putExtra(Constants.KEY_IS_PLUGIN, true);
        intent.putExtra(Constants.KEY_TARGET_PACKAGE, targetPackageName);
        intent.putExtra(Constants.KEY_TARGET_ACTIVITY, targetClassName);
        dispatchStubActivity(intent);
    }
}

在该方法中判断如果启动的是插件中类,则将启动的包名和Activity类名存到了intent中,可以看到这里存储明显是为了后面恢复用的。

然后调用了dispatchStubActivity(intent)

private void dispatchStubActivity(Intent intent) {
    ComponentName component = intent.getComponent();
    String targetClassName = intent.getComponent().getClassName();
    LoadedPlugin loadedPlugin = mPluginManager.getLoadedPlugin(intent);
    ActivityInfo info = loadedPlugin.getActivityInfo(component);
    if (info == null) {
        throw new RuntimeException("can not find " + component);
    }
    int launchMode = info.launchMode;
    Resources.Theme themeObj = loadedPlugin.getResources().newTheme();
    themeObj.applyStyle(info.theme, true);
    String stubActivity = mStubActivityInfo.getStubActivity(targetClassName, launchMode, themeObj);
    Log.i(TAG, String.format("dispatchStubActivity,[%s -> %s]", targetClassName, stubActivity));
    intent.setClassName(mContext, stubActivity);
}

可以直接看最后一行,intent通过setClassName替换启动的目标Activity了!这个stubActivity是由mStubActivityInfo.getStubActivity(targetClassName, launchMode, themeObj)返回。

很明显,传入的参数launchMode、themeObj都是决定选择哪一个占坑类用的。

public String getStubActivity(String className, int launchMode, Theme theme) {
    String stubActivity= mCachedStubActivity.get(className);
    if (stubActivity != null) {
        return stubActivity;
    }

    TypedArray array = theme.obtainStyledAttributes(new int[]{
            钱柜娱乐开户.R.attr.windowIsTranslucent,
            钱柜娱乐开户.R.attr.windowBackground
    });
    boolean windowIsTranslucent = array.getBoolean(0, false);
    array.recycle();
    if (Constants.DEBUG) {
        Log.d("StubActivityInfo", "getStubActivity, is transparent theme ? " + windowIsTranslucent);
    }
    stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, usedStandardStubActivity);
    switch (launchMode) {
        case ActivityInfo.LAUNCH_MULTIPLE: {
            stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, usedStandardStubActivity);
            if (windowIsTranslucent) {
                stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, 2);
            }
            break;
        }
        case ActivityInfo.LAUNCH_SINGLE_TOP: {
            usedSingleTopStubActivity = usedSingleTopStubActivity % MAX_COUNT_SINGLETOP + 1;
            stubActivity = String.format(STUB_ACTIVITY_SINGLETOP, corePackage, usedSingleTopStubActivity);
            break;
        }

       // 省略LAUNCH_SINGLE_TASK,LAUNCH_SINGLE_INSTANCE
    }

    mCachedStubActivity.put(className, stubActivity);
    return stubActivity;
}

可以看到主要就是根据launchMode去选择不同的占坑类。
例如:

stubActivity = String.format(STUB_ACTIVITY_STANDARD, corePackage, usedStandardStubActivity);

STUB_ACTIVITY_STANDARD值为:"%s.A$%d", corePackage值为com.didi.virtualapk.core,usedStandardStubActivity为数字值。

所以最终类名格式为:com.didi.virtualapk.core.A$1

再看一眼,CoreLibrary下的钱柜娱乐开户Manifest中:

<activity 钱柜娱乐开户:name=".A$1" 钱柜娱乐开户:launchMode="standard"/>
<activity 钱柜娱乐开户:name=".A$2" 钱柜娱乐开户:launchMode="standard"
    钱柜娱乐开户:theme="@钱柜娱乐开户:style/Theme.Translucent" />

<!-- Stub Activities -->
<activity 钱柜娱乐开户:name=".B$1" 钱柜娱乐开户:launchMode="singleTop"/>
<activity 钱柜娱乐开户:name=".B$2" 钱柜娱乐开户:launchMode="singleTop"/>
<activity 钱柜娱乐开户:name=".B$3" 钱柜娱乐开户:launchMode="singleTop"/>
// 省略很多...    

就完全明白了。

到这里就可以看到,替换我们启动的Activity为占坑Activity,将我们原本启动的包名,类名存储到了Intent中。

这样做只完成了一半,为什么这么说呢?

(2) 还原Activity

因为欺骗过了AMS,AMS执行完成后,最终要启动的不可能是占坑Activity,还应该是我们的启动的目标Activity呀。

这里需要知道Activity的启动流程:

AMS在处理完启动Activity后,会调用:app.thread.scheduleLaunchActivity,这里的thread对应的server端未我们ActivityThread中的ApplicationThread对象(binder可以理解有一个client端和一个server端),所以会调用ApplicationThread.scheduleLaunchActivity方法,在其内部会调用mH类的sendMessage方法,传递的标识为H.LAUNCH_ACTIVITY,进入调用到ActivityThread的handleLaunchActivity方法->ActivityThread#handleLaunchActivity->mInstrumentation.newActivity()。

ps:这里流程不清楚没关系,暂时理解为最终会回调到Instrumentation的newActivity方法即可,细节可以自己去查看结合老罗的blog理解。

关键的来了,最终又到了Instrumentation的newActivity方法,还记得这个类我们已经改为VAInstrumentation啦:

直接看其newActivity方法:

@Override
public Activity newActivity(ClassLoader cl, String className, Intent intent) throws InstantiationException, IllegalAccessException, ClassNotFoundException {
    try {
        cl.loadClass(className);
    } catch (ClassNotFoundException e) {
        LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(intent);
        String targetClassName = PluginUtil.getTargetActivity(intent);

        if (targetClassName != null) {
            Activity activity = mBase.newActivity(plugin.getClassLoader(), targetClassName, intent);
            activity.setIntent(intent);

          // 省略兼容性处理代码
            return activity;
        }
    }

    return mBase.newActivity(cl, className, intent);
}

核心就是首先从intent中取出我们的目标Activity,然后通过plugin的ClassLoader去加载(还记得在加载插件时,会生成一个LoadedPlugin对象,其中会对应其初始化一个DexClassLoader)。

这样就完成了Activity的“偷梁换柱”。

还没完,接下来在callActivityOnCreate方法中:

 @Override
public void callActivityOnCreate(Activity activity, Bundle icicle) {
    final Intent intent = activity.getIntent();
    if (PluginUtil.isIntentFromPlugin(intent)) {
        Context base = activity.getBaseContext();
        try {
            LoadedPlugin plugin = this.mPluginManager.getLoadedPlugin(intent);
            ReflectUtil.setField(base.getClass(), base, "mResources", plugin.getResources());
            ReflectUtil.setField(ContextWrapper.class, activity, "mBase", plugin.getPluginContext());
            ReflectUtil.setField(Activity.class, activity, "mApplication", plugin.getApplication());
            ReflectUtil.setFieldNoException(ContextThemeWrapper.class, activity, "mBase", plugin.getPluginContext());

            // set screenOrientation
            ActivityInfo activityInfo = plugin.getActivityInfo(PluginUtil.getComponent(intent));
            if (activityInfo.screenOrientation != ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) {
                activity.setRequestedOrientation(activityInfo.screenOrientation);
            }
        } catch (Exception e) {
            e.printStackTrace();
        }

    }

    mBase.callActivityOnCreate(activity, icicle);
}

设置了修改了mResources、mBase(Context)、mApplication对象。以及设置一些可动态设置的属性,这里仅设置了屏幕方向。

这里提一下,将mBase替换为PluginContext,可以修改Resources、AssetManager以及拦截相当多的操作。

看一眼代码就清楚了:

原本Activity的部分get操作

# ContextWrapper
@Override
public AssetManager getAssets() {
    return mBase.getAssets();
}

@Override
public Resources getResources()
{
    return mBase.getResources();
}

@Override
public PackageManager getPackageManager() {
    return mBase.getPackageManager();
}

@Override
public ContentResolver getContentResolver() {
    return mBase.getContentResolver();
}

直接替换为:

# PluginContext

@Override
public Resources getResources() {
    return this.mPlugin.getResources();
}

@Override
public AssetManager getAssets() {
    return this.mPlugin.getAssets();
}

@Override
public ContentResolver getContentResolver() {
    return new PluginContentResolver(getHostContext());
}

看得出来还是非常巧妙的。可以做的事情也非常多,后面对ContentProvider的描述也会提现出来。

好了,到此Activity就可以正常启动了。

下面看Service。

三、Service的支持

Service和Activity有点不同,显而易见的首先我们也会将要启动的Service类替换为占坑的Service类,但是有一点不同,在Standard模式下多次启动同一个占坑Activity会创建多个对象来对象我们的目标类。而Service多次启动只会调用onStartCommond方法,甚至常规多次调用bindService,seviceConn对象不变,甚至都不会多次回调bindService方法(多次调用可以通过给Intent设置不同Action解决)。

还有一点,最明显的差异是,Activity的生命周期是由用户交互决定的,而Service的声明周期是我们主动通过代码调用的。

也就是说,start、stop、bind、unbind都是我们显示调用的,所以我们可以拦截这几个方法,做一些事情。

Virtual Apk的做法,即将所有的操作进行拦截,都改为startService,然后统一在onStartCommond中分发。

下面看详细代码:

(1) hook IActivityManager

再次来到PluginManager,发下如下方法:

private void hookSystemServices() {
    try {
        Singleton<IActivityManager> defaultSingleton = (Singleton<IActivityManager>) ReflectUtil.getField(ActivityManagerNative.class, null, "gDefault");
        IActivityManager activityManagerProxy = ActivityManagerProxy.newInstance(this, defaultSingleton.get());

        // Hook IActivityManager from ActivityManagerNative
        ReflectUtil.setField(defaultSingleton.getClass().getSuperclass(), defaultSingleton, "mInstance", activityManagerProxy);

        if (defaultSingleton.get() == activityManagerProxy) {
            this.mActivityManager = activityManagerProxy;
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

首先拿到ActivityManagerNative中的gDefault对象,该对象返回的是一个Singleton<IActivityManager>,然后拿到其mInstance对象,即IActivityManager对象(可以理解为和AMS交互的binder的client对象)对象。

然后通过动态代理的方式,替换为了一个代理对象。

那么重点看对应的InvocationHandler对象即可,该代理对象调用的方法都会辗转到其invoke方法:

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    if ("startService".equals(method.getName())) {
        try {
            return startService(proxy, method, args);
        } catch (Throwable e) {
            Log.e(TAG, "Start service error", e);
        }
    } else if ("stopService".equals(method.getName())) {
        try {
            return stopService(proxy, method, args);
        } catch (Throwable e) {
            Log.e(TAG, "Stop Service error", e);
        }
    } else if ("stopServiceToken".equals(method.getName())) {
        try {
            return stopServiceToken(proxy, method, args);
        } catch (Throwable e) {
            Log.e(TAG, "Stop service token error", e);
        }
    }
    // 省略bindService,unbindService等方法
}    

当我们调用startService时,跟进代码,可以发现调用流程为:

startService->startServiceCommon->ActivityManagerNative.getDefault().startService

这个getDefault刚被我们hook,所以会被上述方法拦截,然后调用:startService(proxy, method, args)

private Object startService(Object proxy, Method method, Object[] args) throws Throwable {
    IApplicationThread appThread = (IApplicationThread) args[0];
    Intent target = (Intent) args[1];
    ResolveInfo resolveInfo = this.mPluginManager.resolveService(target, 0);
    if (null == resolveInfo || null == resolveInfo.serviceInfo) {
        // is host service
        return method.invoke(this.mActivityManager, args);
    }

    return startDelegateServiceForTarget(target, resolveInfo.serviceInfo, null, RemoteService.EXTRA_COMMAND_START_SERVICE);
}

先不看代码,考虑下我们这里唯一要做的就是通过Intent保存关键数据,替换启动的Service类为占坑类。

所以直接看最后的方法:

private ComponentName startDelegateServiceForTarget(Intent target,
                                                    ServiceInfo serviceInfo,
                                                    Bundle extras, int command) {
    Intent wrapperIntent = wrapperTargetIntent(target, serviceInfo, extras, command);
    return mPluginManager.getHostContext().startService(wrapperIntent);
}

最后一行就是启动了,那么替换的操作应该在wrapperTargetIntent中完成:

private Intent wrapperTargetIntent(Intent target, ServiceInfo serviceInfo, Bundle extras, int command) {
    // fill in service with ComponentName
    target.setComponent(new ComponentName(serviceInfo.packageName, serviceInfo.name));
    String pluginLocation = mPluginManager.getLoadedPlugin(target.getComponent()).getLocation();

    // start delegate service to run plugin service inside
    boolean local = PluginUtil.isLocalService(serviceInfo);
    Class<? extends Service> delegate = local ? LocalService.class : RemoteService.class;
    Intent intent = new Intent();
    intent.setClass(mPluginManager.getHostContext(), delegate);
    intent.putExtra(RemoteService.EXTRA_TARGET, target);
    intent.putExtra(RemoteService.EXTRA_COMMAND, command);
    intent.putExtra(RemoteService.EXTRA_PLUGIN_LOCATION, pluginLocation);
    if (extras != null) {
        intent.putExtras(extras);
    }

    return intent;
}

果不其然,重新初始化了Intent,设置了目标类为LocalService(多进程时设置为RemoteService),然后将原本的Intent存储到EXTRA_TARGET,携带command为EXTRA_COMMAND_START_SERVICE,以及插件apk路径。

(2)代理分发

那么接下来代码就到了LocalService的onStartCommond中啦:


@Override
public int onStartCommand(Intent intent, int flags, int startId) {
    // 省略一些代码...

    Intent target = intent.getParcelableExtra(EXTRA_TARGET);
    int command = intent.getIntExtra(EXTRA_COMMAND, 0);
    if (null == target || command <= 0) {
        return START_STICKY;
    }

    ComponentName component = target.getComponent();
    LoadedPlugin plugin = mPluginManager.getLoadedPlugin(component);

    switch (command) {
        case EXTRA_COMMAND_START_SERVICE: {
            ActivityThread mainThread = (ActivityThread)ReflectUtil.getActivityThread(getBaseContext());
            IApplicationThread appThread = mainThread.getApplicationThread();
            Service service;

            if (this.mPluginManager.getComponentsHandler().isServiceAvailable(component)) {
                service = this.mPluginManager.getComponentsHandler().getService(component);
            } else {
                try {
                    service = (Service) plugin.getClassLoader().loadClass(component.getClassName()).newInstance();

                    Application app = plugin.getApplication();
                    IBinder token = appThread.asBinder();
                    Method attach = service.getClass().getMethod("attach", Context.class, ActivityThread.class, String.class, IBinder.class, Application.class, Object.class);
                    IActivityManager am = mPluginManager.getActivityManager();

                    attach.invoke(service, plugin.getPluginContext(), mainThread, component.getClassName(), token, app, am);
                    service.onCreate();
                    this.mPluginManager.getComponentsHandler().rememberService(component, service);
                } catch (Throwable t) {
                    return START_STICKY;
                }
            }

            service.onStartCommand(target, 0, this.mPluginManager.getComponentsHandler().getServiceCounter(service).getAndIncrement());
            break;
        }
        // 省略下面的代码
         case EXTRA_COMMAND_BIND_SERVICE:break;
         case EXTRA_COMMAND_STOP_SERVICE:break;
         case EXTRA_COMMAND_UNBIND_SERVICE:break;
}

这里代码很简单了,根据command类型,比如EXTRA_COMMAND_START_SERVICE,直接通过plugin的ClassLoader去load目标Service的class,然后反射创建实例。比较重要的是,Service创建好后,需要调用它的attach方法,这里凑够参数,然后反射调用即可,最后调用onCreate、onStartCommand收工。然后将其保存起来,stop的时候取出来调用其onDestroy即可。

bind、unbind以及stop的代码与上述基本一致,不在赘述。

唯一提醒的就是,刚才看到还hook了一个方法叫做:stopServiceToken,该方法是什么时候用的呢?

主要有一些特殊的Service,比如IntentService,其stopSelf是由自身调用的,最终会调用mActivityManager.stopServiceToken方法,同样的中转为STOP操作即可。

四、BroadcastReceiver的支持

这个比较简单,直接解析Manifest后,静态转动态即可。

相关代码在LoadedPlugin的构造方法中:

for (PackageParser.Activity receiver : this.mPackage.receivers) {
    receivers.put(receiver.getComponentName(), receiver.info);

    try {
        BroadcastReceiver br = BroadcastReceiver.class.cast(getClassLoader().loadClass(receiver.getComponentName().getClassName()).newInstance());
        for (PackageParser.ActivityIntentInfo aii : receiver.intents) {
            this.mHostContext.registerReceiver(br, aii);
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

可以看到解析到receiver信息后,直接通过pluginClassloader去loadClass拿到receiver对象,然后调用this.mHostContext.registerReceiver即可。

开心,最后一个了~

五、ContentProvider的支持

(1)hook IContentProvider

ContentProvider的支持依然是通过代理分发。

看一段CP使用的代码:

Cursor bookCursor = getContentResolver().query(bookUri, new String[]{"_id", "name"}, null, null, null);

这里用到了PluginContext,在生成Activity、Service的时候,为其设置的Context都为PluginContext对象。

所以当你调用getContentResolver时,调用的为PluginContext的getContentResolver。

@Override
public ContentResolver getContentResolver() {
    return new PluginContentResolver(getHostContext());
}

返回的是一个PluginContentResolver对象,当我们调用query方法时,会辗转调用到
ContentResolver.acquireUnstableProvider方法。该方法被PluginContentResolver中复写:

protected IContentProvider acquireUnstableProvider(Context context, String auth) {
    try {
        if (mPluginManager.resolveContentProvider(auth, 0) != null) {
            return mPluginManager.getIContentProvider();
        }

        return (IContentProvider) sAcquireUnstableProvider.invoke(mBase, context, auth);
    } catch (Exception e) {
        e.printStackTrace();
    }

    return null;
}

如果调用的auth为插件apk中的provider,则直接返回mPluginManager.getIContentProvider()

public synchronized IContentProvider getIContentProvider() {
    if (mIContentProvider == null) {
        hookIContentProviderAsNeeded();
    }

    return mIContentProvider;
}

咦,又看到一个hook方法:

private void hookIContentProviderAsNeeded() {
    Uri uri = Uri.parse(PluginContentResolver.getUri(mContext));
    mContext.getContentResolver().call(uri, "wakeup", null, null);
    try {
        Field authority = null;
        Field mProvider = null;
        ActivityThread activityThread = (ActivityThread) ReflectUtil.getActivityThread(mContext);
        Map mProviderMap = (Map) ReflectUtil.getField(activityThread.getClass(), activityThread, "mProviderMap");
        Iterator iter = mProviderMap.entrySet().iterator();
        while (iter.hasNext()) {
            Map.Entry entry = (Map.Entry) iter.next();
            Object key = entry.getKey();
            Object val = entry.getValue();
            String auth;
            if (key instanceof String) {
                auth = (String) key;
            } else {
                if (authority == null) {
                    authority = key.getClass().getDeclaredField("authority");
                    authority.setAccessible(true);
                }
                auth = (String) authority.get(key);
            }
            if (auth.equals(PluginContentResolver.getAuthority(mContext))) {
                if (mProvider == null) {
                    mProvider = val.getClass().getDeclaredField("mProvider");
                    mProvider.setAccessible(true);
                }
                IContentProvider rawProvider = (IContentProvider) mProvider.get(val);
                IContentProvider proxy = IContentProviderProxy.newInstance(mContext, rawProvider);
                mIContentProvider = proxy;
                Log.d(TAG, "hookIContentProvider succeed : " + mIContentProvider);
                break;
            }
        }
    } catch (Exception e) {
        e.printStackTrace();
    }
}

前两行比较重要,第一行是拿到了占坑的provider的uri,然后主动调用了其call方法。
如果你跟进去,会发现,其会调用acquireProvider->mMainThread.acquireProvider->ActivityManagerNative.getDefault().getContentProvider->installProvider。简单来说,其首先调用已经注册provider,得到返回的IContentProvider对象。

这个IContentProvider对象是在ActivityThread.installProvider方法中加入到mProviderMap中。

而ActivityThread对象又容易获取,mProviderMap又是它成员变量,那么也容易获取,所以上面的一大坨(除了前两行)代码,就为了拿到占坑的provider对应的IContentProvider对象。

然后通过动态代理的方式,进行了hook,关注InvocationHandler的实例IContentProviderProxy。

IContentProvider能干吗呢?其实就能拦截我们正常的query、insert、update、delete等操作。

拦截这些方法干嘛?

当然是修改uri啦,把用户调用的uri,替换为占坑provider的uri,再把原本的uri作为参数拼接在占坑provider的uri后面即可。

好了,直接看invoke方法:

@Override
public Object invoke(Object proxy, Method method, Object[] args) throws Throwable {
    Log.v(TAG, method.toGenericString() + " : " + Arrays.toString(args));
    wrapperUri(method, args);

    try {
        return method.invoke(mBase, args);
    } catch (InvocationTargetException e) {
        throw e.getTargetException();
    }
}

直接看wrapperUri

private void wrapperUri(Method method, Object[] args) {
    Uri uri = null;
    int index = 0;
    if (args != null) {
        for (int i = 0; i < args.length; i++) {
            if (args[i] instanceof Uri) {
                uri = (Uri) args[i];
                index = i;
                break;
            }
        }
    }

    // 省略部分代码

    PluginManager pluginManager = PluginManager.getInstance(mContext);
    ProviderInfo info = pluginManager.resolveContentProvider(uri.getAuthority(), 0);
    if (info != null) {
        String pkg = info.packageName;
        LoadedPlugin plugin = pluginManager.getLoadedPlugin(pkg);
        String pluginUri = Uri.encode(uri.toString());
        StringBuilder builder = new StringBuilder(PluginContentResolver.getUri(mContext));
        builder.append("/?plugin=" + plugin.getLocation());
        builder.append("&pkg=" + pkg);
        builder.append("&uri=" + pluginUri);
        Uri wrapperUri = Uri.parse(builder.toString());
        if (method.getName().equals("call")) {
            bundleInCallMethod.putString(KEY_WRAPPER_URI, wrapperUri.toString());
        } else {
            args[index] = wrapperUri;
        }
    }
}

从参数中找到uri,往下看,搞了个StringBuilder首先加入占坑provider的uri,然后将目标uri,pkg,plugin等参数等拼接上去,替换到args中的uri,然后继续走原本的流程。

假设是query方法,应该就到达我们占坑provider的query方法啦。

(2)代理分发

占坑如下:

<provider
    钱柜娱乐开户:name="com.didi.virtualapk.delegate.RemoteContentProvider"
    钱柜娱乐开户:authorities="${applicationId}.VirtualAPK.Provider"
    钱柜娱乐开户:process=":daemon" />

打开RemoteContentProvider,直接看query方法:

@Override
public Cursor query(Uri uri, String[] projection, String selection,
                    String[] selectionArgs, String sortOrder) {

    ContentProvider provider = getContentProvider(uri);
    Uri pluginUri = Uri.parse(uri.getQueryParameter(KEY_URI));
    if (provider != null) {
        return provider.query(pluginUri, projection, selection, selectionArgs, sortOrder);
    }

    return null;
}

可以看到通过传入的生成了一个新的provider,然后拿到目标uri,在直接调用provider.query传入目标uri即可。

那么这个provider实际上是这个代理类帮我们生成的:

private ContentProvider getContentProvider(final Uri uri) {
    final PluginManager pluginManager = PluginManager.getInstance(getContext());
    Uri pluginUri = Uri.parse(uri.getQueryParameter(KEY_URI));
    final String auth = pluginUri.getAuthority();
    // 省略了缓存管理
    LoadedPlugin plugin = pluginManager.getLoadedPlugin(uri.getQueryParameter(KEY_PKG));
    if (plugin == null) {
        try {
            pluginManager.loadPlugin(new File(uri.getQueryParameter(KEY_PLUGIN)));
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    final ProviderInfo providerInfo = pluginManager.resolveContentProvider(auth, 0);
    if (providerInfo != null) {
        RunUtil.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                try {
                    LoadedPlugin loadedPlugin = pluginManager.getLoadedPlugin(uri.getQueryParameter(KEY_PKG));
                    ContentProvider contentProvider = (ContentProvider) Class.forName(providerInfo.name).newInstance();
                    contentProvider.attachInfo(loadedPlugin.getPluginContext(), providerInfo);
                    sCachedProviders.put(auth, contentProvider);
                } catch (Exception e) {
                    e.printStackTrace();
                }
            }
        }, true);
        return sCachedProviders.get(auth);
    }
    return null;
}

很简单,取出原本的uri,拿到auth,在通过加载plugin得到providerInfo,反射生成provider对象,在调用其attachInfo方法即可。

其他的几个方法:insert、update、delete、call逻辑基本相同,就不赘述了。

感觉这里其实通过hook AMS的getContentProvider方法也能完成上述流程,感觉好像可以更彻底,不需要依赖PluginContext了。

六、总结

总结下,其实就是文初的内容,可以看到VritualApk大体方案如下:

  • Activity:在宿主apk中提前占几个坑,然后通过“欺上瞒下”(这个词好像是360之前的ppt中提到)的方式,启动插件apk的Activity;因为要支持不同的launchMode以及一些特殊的属性,需要占多个坑。
  • Service:通过代理Service的方式去分发;主进程和其他进程,VirtualAPK使用了两个代理Service。
  • BroadcastReceiver:静态转动态。
  • ContentProvider:通过一个代理Provider进行分发。

整体代码看起来还是很轻松的~

当然如果你要选择某一个插件化方案进行使用,一定要了解其中的实现原理,文档上描述的并不是所有细节,很多一些属性什么的,以及由于其实现的方式造成一些特性的不支持。了解源码,可以方便自己排查问题,扩展,甚至写一套根据自己业务需求的插件化方案~~

再多嘴一句,还是建议大多多在某一方面深入了解,不要痴迷于UI特效(上班路上看看我的推文就好啦~玩笑~,很多特效的,了解下原理即可)~~其实我早期浪费了很多时间在上面,在你掌握了自定义View的详细细节、事件分发机制这些机制后,大部分UI的编写都是时间问题。

不要在上面浪费过多时间,比别人多研究几个特效并不会对自己的提升有巨大的帮助,过来人,忠言逆耳~。


支持我的话可以关注下我的公众号,每天都会推送新知识~

欢迎关注我的微信公众号:hongyang钱柜娱乐开户
(可以给我留言你想学习的文章,支持投稿)

作者:lmj623565791 发表于2017/7/12 0:03:04 原文链接
阅读:19921 评论:43 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/72859156 /lmj623565791/article/details/72859156 lmj623565791 2017/6/9 9:03:46

本文已在我的公众号hongyang钱柜娱乐开户原创首发。
转载请标明出处:
/lmj623565791/article/details/72859156
本文出自张鸿洋的博客

本文已在我的公众号hongyang钱柜娱乐开户原创首发,文章合集

一、概述

之前项目的新特性适配工作都是同事在做,一直没有怎么太关注,不过类似这些适配的工作还是有必要做一些记录的。

对于钱柜娱乐开户 7.0,提供了非常多的变化,详细的可以阅读官方文档钱柜娱乐开户 7.0 行为变更,记得当时做了多窗口支持、FileProvider以及7.1的3D Touch的支持,不过和我们开发者关联最大的,或者说必须要适配的就是去除项目中传递file://类似格式的uri了。

在官方7.0的以上的系统中,尝试传递 file://URI可能会触发FileUriExposedException

所以本文主要描述如何适配该问题,没什么难度,仅做记录。

注:本文targetSdkVersion 25 ,compileSdkVersion 25

二、拍照案例

大家应该对于手机拍照一定都不陌生,在希望得到一张高清拍照图的时候,我们通过Intent会传递一个File的Uri给相机应用。

大致代码如下:

private static final int REQUEST_CODE_TAKE_PHOTO = 0x110;
    private String mCurrentPhotoPath;

    public void takePhotoNoCompress(View view) {
        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        if (takePictureIntent.resolveActivity(getPackageManager()) != null) {

            String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA)
                    .format(new Date()) + ".png";
            File file = new File(Environment.getExternalStorageDirectory(), filename);
            mCurrentPhotoPath = file.getAbsolutePath();

            takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(file));
            startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO);
        }
    }

    @Override
    protected void onActivityResult(int requestCode, int resultCode, Intent data) {
        super.onActivityResult(requestCode, resultCode, data);
        if (resultCode == RESULT_OK && requestCode == REQUEST_CODE_TAKE_PHOTO) {
            mIvPhoto.setImageBitmap(BitmapFactory.decodeFile(mCurrentPhotoPath));
        }
        // else tip?

    }

贴个效果图吧~

未处理6.0权限,有需要的自行处理下,nexus系列如果未处理,需要手动在设置页开启存储权限。

此时如果我们使用钱柜娱乐开户 7.0或者以上的原生系统,再次运行一下,你会发现应用直接停止运行,抛出了钱柜娱乐开户.os.FileUriExposedException

Caused by: 钱柜娱乐开户.os.FileUriExposedException: 
    file:///storage/emulated/0/20170601-030254.png 
        exposed beyond app through ClipData.Item.getUri()
    at 钱柜娱乐开户.os.StrictMode.onFileUriExposed(StrictMode.java:1932)
    at 钱柜娱乐开户.net.Uri.checkFileUriExposed(Uri.java:2348)

所以如果你意识到自己写的代码,在7.0的原生系统的手机上直接就crash是不是很方~

原因在官网已经给了解释:

对于面向 钱柜娱乐开户 7.0 的应用,钱柜娱乐开户 框架执行的 StrictMode API 政策禁止在您的应用外部公开 file:// URI。如果一项包含文件 URI 的 intent 离开您的应用,则应用出现故障,并出现 FileUriExposedException 异常。

同样的,官网也给出了解决方案:

要在应用间共享文件,您应发送一项 content:// URI,并授予 URI 临时访问权限。进行此授权的最简单方式是使用 FileProvider 类。如需了解有关权限和共享文件的详细信息,请参阅共享文件。
/lmj623565791/rss/https://developer.钱柜娱乐开户.com/about/versions/nougat/钱柜娱乐开户-7.0-changes.html#accessibility

那么下面就看看如何通过FileProvider解决此问题吧。

三、使用FileProvider兼容拍照

其实对于如何使用FileProvider,其实在FileProvider的API页面也有详细的步骤,有兴趣的可以看下。

/lmj623565791/rss/https://developer.钱柜娱乐开户.com/reference/钱柜娱乐开户/support/v4/content/FileProvider.html

FileProvider实际上是ContentProvider的一个子类,它的作用也比较明显了,file:///Uri不给用,那么换个Uri为content://来替代。

下面我们看下整体的实现步骤,并考虑为什么需要怎么做?

(1)声明provider

<provider
    钱柜娱乐开户:name="钱柜娱乐开户.support.v4.content.FileProvider"
    钱柜娱乐开户:authorities="com.zhy.钱柜娱乐开户7.fileprovider"
    钱柜娱乐开户:exported="false"
    钱柜娱乐开户:grantUriPermissions="true">
    <meta-data
        钱柜娱乐开户:name="钱柜娱乐开户.support.FILE_PROVIDER_PATHS"
        钱柜娱乐开户:resource="@xml/file_paths" />
</provider>

为什么要声明呢?因为FileProvider是ContentProvider子类哇~~

注意一点,他需要设置一个meta-data,里面指向一个xml文件。

(2)编写resource xml file

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:钱柜娱乐开户="http://schemas.钱柜娱乐开户.com/apk/res/钱柜娱乐开户">
    <root-path name="root" path="" />
    <files-path name="files" path="" />
    <cache-path name="cache" path="" />
    <external-path name="external" path="" />
    <external-files-path name="name" path="path" />
     <external-cache-path name="name" path="path" />
</paths>

在paths节点内部支持以下几个子节点,分别为:

  • <root-path/> 代表设备的根目录new File("/");
  • <files-path/> 代表context.getFilesDir()
  • <cache-path/> 代表context.getCacheDir()
  • <external-path/> 代表Environment.getExternalStorageDirectory()
  • <external-files-path>代表context.getExternalFilesDirs()
  • <external-cache-path>代表getExternalCacheDirs()

每个节点都支持两个属性:

  • name
  • path

path即为代表目录下的子目录,比如:

<external-path
        name="external"
        path="pics" />

代表的目录即为:Environment.getExternalStorageDirectory()/pics,其他同理。

当这么声明以后,代码可以使用你所声明的当前文件夹以及其子文件夹。

本例使用的是SDCard所以这么写即可:

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:钱柜娱乐开户="http://schemas.钱柜娱乐开户.com/apk/res/钱柜娱乐开户">
    <external-path name="external" path="" />
</paths>

为了简单,我们直接使用SDCard根目录,所以path里面就不填写子目录了~

这里你可能会有疑问,为什么要写这么个xml文件,有啥用呀?

刚才我们说了,现在要使用content://uri替代file://uri,那么,content://的uri如何定义呢?总不能使用文件路径吧,那不是骗自己么~

所以,需要一个虚拟的路径对文件路径进行映射,所以需要编写个xml文件,通过path以及xml节点确定可访问的目录,通过name属性来映射真实的文件路径。

(3)使用FileProvider API

好了,接下来就可以通过FileProvider把我们的file转化为content://uri了~

public void takePhotoNoCompress(View view) {
        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
        if (takePictureIntent.resolveActivity(getPackageManager()) != null) {

            String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA)
                    .format(new Date()) + ".png";
            File file = new File(Environment.getExternalStorageDirectory(), filename);
            mCurrentPhotoPath = file.getAbsolutePath();

            Uri fileUri = FileProvider.getUriForFile(this, "com.zhy.钱柜娱乐开户7.fileprovider", file);
            takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri);
            startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO);
        }
    }

核心代码就这一行了~

FileProvider.getUriForFile(this, "com.zhy.钱柜娱乐开户7.fileprovider", file);

第二个参数就是我们配置的authorities,这个很正常了,总得映射到确定的ContentProvider吧~所以需要这个参数。

然后再看一眼我们生成的uri:

content://com.zhy.钱柜娱乐开户7.fileprovider/external/20170601-041411.png

可以看到格式为:content://authorities/定义的name属性/文件的相对路径,即name隐藏了可存储的文件夹路径。

现在拿7.0的原生手机运行就正常啦~

不过事情到此并没有结束~~

打开一个4.4的模拟器,运行上述代码,你会发现又Crash啦,抛出了:Permission Denial~

Caused by: java.lang.SecurityException: Permission Denial: opening provider 钱柜娱乐开户.support.v4.content.FileProvider from ProcessRecord{52b029b8 1670:com.钱柜娱乐开户.camera/u0a36} (pid=1670, uid=10036) that is not exported from uid 10052
at 钱柜娱乐开户.os.Parcel.readException(Parcel.java:1465)
at 钱柜娱乐开户.os.Parcel.readException(Parcel.java:1419)
at 钱柜娱乐开户.app.ActivityManagerProxy.getContentProvider(ActivityManagerNative.java:2848)
at 钱柜娱乐开户.app.ActivityThread.acquireProvider(ActivityThread.java:4399)

因为低版本的系统,仅仅是把这个当成一个普通的Provider在使用,而我们没有授权,contentprovider的export设置的也是false;导致Permission Denial

那么,我们是否可以将export设置为true呢?

很遗憾是不能的。

在FileProvider的内部:

@Override
public void attachInfo(Context context, ProviderInfo info) {
    super.attachInfo(context, info);

    // Sanity check our security
    if (info.exported) {
        throw new SecurityException("Provider must not be exported");
    }
    if (!info.grantUriPermissions) {
        throw new SecurityException("Provider must grant uri permissions");
    }

    mStrategy = getPathStrategy(context, info.authority);
}

确定了exported必须是false,grantUriPermissions必须是true ~~

所以唯一的办法就是授权了~

context提供了两个方法:

  • grantUriPermission(String toPackage, Uri uri,
    int modeFlags)
  • revokeUriPermission(Uri uri, int modeFlags);

可以看到grantUriPermission需要传递一个包名,就是你给哪个应用授权,但是很多时候,比如分享,我们并不知道最终用户会选择哪个app,所以我们可以这样:

List<ResolveInfo> resInfoList = context.getPackageManager()
            .queryIntentActivities(intent, PackageManager.MATCH_DEFAULT_ONLY);
for (ResolveInfo resolveInfo : resInfoList) {
    String packageName = resolveInfo.activityInfo.packageName;
    context.grantUriPermission(packageName, uri, flag);
}

根据Intent查询出的所以符合的应用,都给他们授权~~

恩,你可以在不需要的时候通过revokeUriPermission移除权限~

那么增加了授权后的代码是这样的:

public void takePhotoNoCompress(View view) {
    Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
    if (takePictureIntent.resolveActivity(getPackageManager()) != null) {

        String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA)
                .format(new Date()) + ".png";
        File file = new File(Environment.getExternalStorageDirectory(), filename);
        mCurrentPhotoPath = file.getAbsolutePath();

        Uri fileUri = FileProvider.getUriForFile(this, "com.zhy.钱柜娱乐开户7.fileprovider", file);

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

        takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri);
        startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO);
    }
}

这样就搞定了,不过还是挺麻烦的,如果你仅仅是对旧系统做兼容,还是建议做一下版本校验即可,也就是说不要管什么授权了,直接这样获取uri

Uri fileUri = null;
if (Build.VERSION.SDK_INT >= 24) {
    fileUri = FileProvider.getUriForFile(this, "com.zhy.钱柜娱乐开户7.fileprovider", file);
} else {
    fileUri = Uri.fromFile(file);
}

这样会比较方便~也避免导致一些问题。当然了,完全使用uri也有一些好处,比如你可以使用私有目录去存储拍摄的照片~

文章最后会给出快速适配的方案~~不需要这么麻烦~

好像,还有什么知识点没有提到,再看一个例子吧~

四、使用FileProvider兼容安装apk

正常我们在编写安装apk的时候,是这样的:

public void installApk(View view) {
    File file = new File(Environment.getExternalStorageDirectory(), "test钱柜娱乐开户7-debug.apk");

    Intent intent = new Intent(Intent.ACTION_VIEW);
    intent.setDataAndType(Uri.fromFile(file),
            "application/vnd.钱柜娱乐开户.package-archive");
    startActivity(intent);
}

拿个7.0的原生手机跑一下,钱柜娱乐开户.os.FileUriExposedException又来了~~

钱柜娱乐开户.os.FileUriExposedException: file:///storage/emulated/0/test钱柜娱乐开户7-debug.apk exposed beyond app through Intent.getData()

好在有经验了,简单修改下uri的获取方式。

if (Build.VERSION.SDK_INT >= 24) {
    fileUri = FileProvider.getUriForFile(this, "com.zhy.钱柜娱乐开户7.fileprovider", file);
} else {
    fileUri = Uri.fromFile(file);
}

再跑一次,没想到还是抛出了异常(警告,没有Crash):

java.lang.SecurityException: Permission Denial: 
opening provider 钱柜娱乐开户.support.v4.content.FileProvider 
        from ProcessRecord{18570a 27107:com.google.钱柜娱乐开户.packageinstaller/u0a26} (pid=27107, uid=10026) that is not exported from UID 10004

可以看到是权限问题,对于权限我们刚说了一种方式为grantUriPermission,这种方式当然是没问题的啦~

加上后运行即可。

其实对于权限,还提供了一种方式,即:

intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);

我们可以在安装包之前加上上述代码,再次运行正常啦~

现在我有两个非常疑惑的问题:

  • 问题1:为什么刚才拍照的时候,钱柜娱乐开户 7的设备并没有遇到Permission Denial的问题?

恩,之所以不需要权限,主要是因为Intent的action为ACTION_IMAGE_CAPTURE,当我们startActivity后,会辗转调用Instrumentation的execStartActivity方法,在该方法内部,会调用intent.migrateExtraStreamToClipData();方法。

该方法中包含:

if (MediaStore.ACTION_IMAGE_CAPTURE.equals(action)
        || MediaStore.ACTION_IMAGE_CAPTURE_SECURE.equals(action)
        || MediaStore.ACTION_VIDEO_CAPTURE.equals(action)) {
    final Uri output;
    try {
        output = getParcelableExtra(MediaStore.EXTRA_OUTPUT);
    } catch (ClassCastException e) {
        return false;
    }
    if (output != null) {
        setClipData(ClipData.newRawUri("", output));
        addFlags(FLAG_GRANT_WRITE_URI_PERMISSION|FLAG_GRANT_READ_URI_PERMISSION);
        return true;
    }
}

可以看到将我们的EXTRA_OUTPUT,转为了setClipData,并直接给我们添加了WRITE和READ权限。

注:该部分逻辑应该是21之后添加的。

  • 问题2:为什么刚才拍照案例的时候,钱柜娱乐开户 4.4设备遇到权限问题,不通过addFlags这种方式解决?

因为addFlags主要用于setDatasetDataAndType以及setClipData(注意:4.4时,并没有将ACTION_IMAGE_CAPTURE转为setClipData实现)这种方式。

所以addFlags方式对于ACTION_IMAGE_CAPTURE在5.0以下是无效的,所以需要使用grantUriPermission,如果是正常的通过setData分享的uri,使用addFlags是没有问题的(可以写个简单的例子测试下,两个app交互,通过content://)。

五、总结下

终于将知识点都涵盖到了~

总结下,使用content://替代file://,主要需要FileProvider的支持,而因为FileProvider是ContentProvider的子类,所以需要在钱柜娱乐开户Manifest.xml中注册;而又因为需要对真实的filepath进行映射,所以需要编写一个xml文档,用于描述可使用的文件夹目录,以及通过name去映射该文件夹目录。

对于权限,有两种方式:

  • 方式一为Intent.addFlags,该方式主要用于针对intent.setData,setDataAndType以及setClipData相关方式传递uri的。
  • 方式二为grantUriPermission来进行授权

相比来说方式二较为麻烦,因为需要指定目标应用包名,很多时候并不清楚,所以需要通过PackageManager进行查找到所有匹配的应用,全部进行授权。不过更为稳妥~

方式一较为简单,对于intent.setData,setDataAndType正常使用即可,但是对于setClipData,由于5.0前后Intent#migrateExtraStreamToClipData,代码发生变化,需要注意~

好了,看到现在是不是觉得适配7.0挺麻烦的,其实一点都不麻烦,下面给大家总结一种快速适配的方式。

六、快速完成适配

(1)新建一个module

创建一个library的module,在其钱柜娱乐开户Manifest.xml中完成FileProvider的注册,代码编写为:

<application>
    <provider
        钱柜娱乐开户:name="钱柜娱乐开户.support.v4.content.FileProvider"
        钱柜娱乐开户:authorities="${applicationId}.钱柜娱乐开户7.fileprovider"
        钱柜娱乐开户:exported="false"
        钱柜娱乐开户:grantUriPermissions="true">
        <meta-data
            钱柜娱乐开户:name="钱柜娱乐开户.support.FILE_PROVIDER_PATHS"
            钱柜娱乐开户:resource="@xml/file_paths" />
    </provider>
</application>

注意一点,钱柜娱乐开户:authorities不要写死,因为该library最终可能会让多个项目引用,而钱柜娱乐开户:authorities是不可以重复的,如果两个app中定义了相同的,则后者无法安装到手机中(authority conflict)。

同样的的编写file_paths~

<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:钱柜娱乐开户="http://schemas.钱柜娱乐开户.com/apk/res/钱柜娱乐开户">
    <root-path
        name="root"
        path="" />
    <files-path
        name="files"
        path="" />

    <cache-path
        name="cache"
        path="" />

    <external-path
        name="external"
        path="" />

    <external-files-path
        name="external_file_path"
        path="" />
    <external-cache-path
        name="external_cache_path"
        path="" />

</paths>

最后再编写一个辅助类,例如:

public class FileProvider7 {

    public static Uri getUriForFile(Context context, File file) {
        Uri fileUri = null;
        if (Build.VERSION.SDK_INT >= 24) {
            fileUri = getUriForFile24(context, file);
        } else {
            fileUri = Uri.fromFile(file);
        }
        return fileUri;
    }

    public static Uri getUriForFile24(Context context, File file) {
        Uri fileUri = 钱柜娱乐开户.support.v4.content.FileProvider.getUriForFile(context,
                context.getPackageName() + ".钱柜娱乐开户7.fileprovider",
                file);
        return fileUri;
    }


    public static void setIntentDataAndType(Context context,
                                            Intent intent,
                                            String type,
                                            File file,
                                            boolean writeAble) {
        if (Build.VERSION.SDK_INT >= 24) {
            intent.setDataAndType(getUriForFile(context, file), type);
            intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
            if (writeAble) {
                intent.addFlags(Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
            }
        } else {
            intent.setDataAndType(Uri.fromFile(file), type);
        }
    }
}

可以根据自己的需求添加方法。

好了,这样我们的一个小库就写好了~~

(2)使用

如果哪个项目需要适配7.0,那么只需要这样引用这个库,然后只需要改动一行代码即可完成适配啦,例如:

拍照

public void takePhotoNoCompress(View view) {
        Intent takePictureIntent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE);
    if (takePictureIntent.resolveActivity(getPackageManager()) != null) {
        String filename = new SimpleDateFormat("yyyyMMdd-HHmmss", Locale.CHINA)
                .format(new Date()) + ".png";
        File file = new File(Environment.getExternalStorageDirectory(), filename);
        mCurrentPhotoPath = file.getAbsolutePath();

        Uri fileUri = FileProvider7.getUriForFile(this, file);
        takePictureIntent.putExtra(MediaStore.EXTRA_OUTPUT, fileUri);
        startActivityForResult(takePictureIntent, REQUEST_CODE_TAKE_PHOTO);
    }
}

只需要改动

 Uri fileUri = FileProvider7.getUriForFile(this, file);

即可。

安装apk

同样的修改setDataAndType为:

FileProvider7.setIntentDataAndType(this,
      intent, "application/vnd.钱柜娱乐开户.package-archive", file, true);

即可。

ok,繁琐的重复性操作终于简化为一行代码啦~

源码地址:

https://github.com/hongyang钱柜娱乐开户/Fit钱柜娱乐开户7


支持我的话可以关注下我的公众号,每天都会推送新知识~

欢迎关注我的微信公众号:hongyang钱柜娱乐开户
(可以给我留言你想学习的文章,支持投稿)

参考

作者:lmj623565791 发表于2017/6/9 9:03:46 原文链接
阅读:43737 评论:53 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/72667669 /lmj623565791/article/details/72667669 lmj623565791 2017/5/23 23:13:53

本文已在我的公众号hongyang钱柜娱乐开户原创首发。
转载请标明出处:
/lmj623565791/article/details/72667669
本文出自张鸿洋的博客

一、概述

前面写了两篇分析了tinker的loader部分源码以及dex diff/patch算法相关解析,那么为了保证完整性,最后一篇主要写tinker-patch-gradle-plugin相关了。

(距离看的时候已经快两个月了,再不写就忘了,赶紧记录下来)

注意:

本文基于1.7.7

前两篇文章分别为:

有兴趣的可以查看~

在介绍细节之前,我们可以先考虑下:通过一个命令生成一个patch文件,这个文件可以用于下发做热修复(可修复常规代码、资源等),那么第一反应是什么呢?

正常思维,需要设置oldApk,然后我这边build生成newApk,两者需要做diff,找出不同的代码、资源,通过特定的算法将diff出来的数据打成patch文件。

ok,的确是这样的,但是上述这个过程有什么需要注意的么?

  1. 我们在新增资源的时候,可能会因为我们新增的一个资源,导致非常多的资源id发生变化,如果这样直接进行diff,可能会导致资源错乱等(id指向了错误的图片)问题。所以应当保证,当资源改变或者新增、删除资源时,早已存在的资源的id不会发生变化。
  2. 我们在上线app的时候,会做代码混淆,如果没有做特殊的设置,每次混淆后的代码难以保证规则一致;所以,build过程中理论上需要设置混淆的mapping文件。
  3. 当项目比较大的时候,我们可能会遇到方法数超过65535的问题,我们很多时候会通过分包解决,这样就有主dex和其他dex的概念。集成了tinker之后,在应用的Application启动时会非常早的就去做tinker的load操作,所以就决定了load相关的类必须在主dex中。
  4. 在接入一些库的时候,往往还需要配置混淆,比如第三方库中哪些东西不能被混淆等(当然强制某些类在主dex中,也可能需要配置相对应的混淆规则)。

如果大家尝试过接入tinker并使用gradle的方式生成patch相关,会发现在需要在项目的build.gradle中,添加一些配置,这些配置中,会要求我们配置oldApk路径,资源的R.txt路径,混淆mapping文件路径、还有一些比较tinker相关的比较细致的配置信息等。

不过并没有要求我们显示去处理上述几个问题(并没有让你去keep混淆规则,主dex分包规则,以及apply mapping文件),所以上述的几个实际上都是tinker的gradle plugin 帮我们做了。

所以,本文将会以这些问题为线索来带大家走一圈plugin的代码(当然实际上tinker gradle plugin所做的事情远不止上述)。

其次,tinker gradle plugin也是非常好的gradle的学习资料~

二、寻找查看代码入口

下载tinker的代码,导入后,plugin的代码都在tinker-patch-gradle-plugin中,不过当然不能抱着代码一行一行去啃了,应该有个明确的入口,有条理的去阅读这些代码。

那么这个入口是什么呢?

其实很简单,我们在打patch的时候,需要执行tinkerPatchDebug(注:本篇博客基于debug模式讲解)。

当执行完后,将会看到执行过程包含以下流程:

:app:processDebugManifest
:app:tinkerProcessDebugManifest(tinker)
:app:tinkerProcessDebugResourceId (tinker)
:app:processDebugResources
:app:tinkerProguardConfigTask(tinker)
:app:transformClassesAndResourcesWithProguard
:app:tinkerProcessDebugMultidexKeep (tinker)
:app:transformClassesWidthMultidexlistForDebug
:app:assembleDebug
:app:tinkerPatchDebug(tinker)

注:包含(tinker)的都是tinker plugin 所添加的task

可以看到部分task加入到了build的流程中,那么这些task是如何加入到build过程中的呢?

在我们接入tinker之后,build.gradle中有如下代码:

if (buildWithTinker()) {
    apply plugin: 'com.tencent.tinker.patch'
    tinkerPatch {} // 各种参数
}

如果开启了tinker,会apply一个plugincom.tencent.tinker.patch

名称实际上就是properties文件的名字,该文件会对应具体的插件类。

对于gradle plugin不了解的,可以参考http://www.cnblogs.com/davenkin/p/gradle-learning-10.html,后面写会抽空单独写一篇详细讲gradle的文章。

下面看TinkerPatchPlugin,在apply方法中,里面大致有类似的代码:

// ... 省略了一堆代码
TinkerPatchSchemaTask tinkerPatchBuildTask 
        = project.tasks.create("tinkerPatch${variantName}", TinkerPatchSchemaTask)
tinkerPatchBuildTask.dependsOn variant.assemble

TinkerManifestTask manifestTask 
        = project.tasks.create("tinkerProcess${variantName}Manifest", TinkerManifestTask)
manifestTask.mustRunAfter variantOutput.processManifest
variantOutput.processResources.dependsOn manifestTask

TinkerResourceIdTask applyResourceTask 
        = project.tasks.create("tinkerProcess${variantName}ResourceId", TinkerResourceIdTask)
applyResourceTask.mustRunAfter manifestTask
variantOutput.processResources.dependsOn applyResourceTask

if (proguardEnable) {
    TinkerProguardConfigTask proguardConfigTask 
            = project.tasks.create("tinkerProcess${variantName}Proguard", TinkerProguardConfigTask)
    proguardConfigTask.mustRunAfter manifestTask

    def proguardTask = getProguardTask(project, variantName)
    if (proguardTask != null) {
        proguardTask.dependsOn proguardConfigTask
    }

}
if (multiDexEnabled) {
    TinkerMultidexConfigTask multidexConfigTask 
            = project.tasks.create("tinkerProcess${variantName}MultidexKeep", TinkerMultidexConfigTask)
    multidexConfigTask.mustRunAfter manifestTask

    def multidexTask = getMultiDexTask(project, variantName)
    if (multidexTask != null) {
        multidexTask.dependsOn multidexConfigTask
    }
}

可以看到它通过gradle Project API创建了5个task,通过dependsOn,mustRunAfter插入到了原本的流程中。

例如:

TinkerManifestTask manifestTask = ...
manifestTask.mustRunAfter variantOutput.processManifest
variantOutput.processResources.dependsOn manifestTask

TinkerManifestTask必须在processManifest之后执行,processResources在manifestTask后执行。

所以流程变为:

processManifest-> manifestTask-> processResources

其他同理。

ok,大致了解了这些task是如何注入的之后,接下来就看看每个task的具体作用吧。

注:如果我们有需求在build过程中搞事,可以参考上述task编写以及依赖方式的设置。

三、每个Task的具体行为

我们按照上述的流程来看,依次为:

TinkerManifestTask
TinkerResourceIdTask
TinkerProguardConfigTask
TinkerMultidexConfigTask
TinkerPatchSchemaTask

丢个图,对应下:

四、TinkerManifestTask

#TinkerManifestTask
@TaskAction
def updateManifest() {
    // Parse the 钱柜娱乐开户Manifest.xml
    String tinkerValue = project.extensions.tinkerPatch.buildConfig.tinkerId

    tinkerValue = TINKER_ID_PREFIX + tinkerValue;//"tinker_id_"

    // /build/intermediates/manifests/full/debug/钱柜娱乐开户Manifest.xml
    writeManifestMeta(manifestPath, TINKER_ID, tinkerValue)

    addApplicationToLoaderPattern()
    File manifestFile = new File(manifestPath)
    if (manifestFile.exists()) {
        FileOperation.copyFileUsingStream(manifestFile, project.file(MANIFEST_XML))
    }
}

这里主要做了两件事:

  • writeManifestMeta主要就是解析钱柜娱乐开户Manifest.xml,在<application>内部添加一个meta标签,value为tinkerValue。

    例如:

     <meta-data
            钱柜娱乐开户:name="TINKER_ID"
            钱柜娱乐开户:value="tinker_id_com.zhy.abc" />

这里不详细展开了,话说groovy解析XML真方便。

  • addApplicationToLoaderPattern主要是记录自己的application类名和tinker相关的一些load class com.tencent.tinker.loader.*,记录在project.extensions.tinkerPatch.dex.loader中。

最后copy修改后的钱柜娱乐开户Manifest.xmlbuild/intermediates/tinker_intermediates/钱柜娱乐开户Manifest.xml

这里我们需要想一下,在文初的分析中,并没有想到需要tinkerId这个东西,那么它到底是干嘛的呢?

看一下微信提供的参数说明,就明白了:

在运行过程中,我们需要验证基准apk包的tinkerId是否等于补丁包的tinkerId。这个是决定补丁包能运行在哪些基准包上面,一般来说我们可以使用git版本号、versionName等等。

想一下,在非强制升级的情况下,线上一般分布着各个版本的app。但是。你打patch肯定是对应某个版本,所以你要保证这个patch下发下去只影响对应的版本,不会对其他版本造成影响,所以你需要tinkerId与具体的版本相对应。

ok,下一个TinkerResourceIdTask。

五、TinkerResourceIdTask

文初提到,打patch的过程实际上要控制已有的资源id不能发生变化,这个task所做的事就是为此。

如果保证已有资源的id保持不变呢?

实际上需要public.xmlids.xml的参与,即预先在public.xml中的如下定义,在第二次打包之后可保持该资源对应的id值不变。

注:对xml文件的名称应该没有强要求。

<public type="id" name="search_button" id="0x7f0c0046" />

很多时候我们在搜索固化资源,一般都能看到通过public.xml去固化资源id,但是这里有个ids.xml是干嘛的呢?

下面这篇文章有个很好的解释~

/sbsujjbcy/article/details/52541803

首先需要生成public.xml,public.xml的生成通过aapt编译时添加-P参数生成。相关代码通过gradle插件去hook Task无缝加入该参数,有一点需要注意,通过appt生成的public.xml并不是可以直接用的,该文件中存在id类型的资源,生成patch时应用进去编译的时候会报resource is not defined,解决方法是将id类型型的资源单独记录到ids.xml文件中,相当于一个声明过程,编译的时候和public.xml一样,将ids.xml也参与编译即可。

ok,知道了public.xml和ids.xml的作用之后,需要再思考一下如何保证id不变?

首先我们在配置old apk的时候,会配置tinkerApplyResourcePath参数,该参数对应一个R.txt,里面的内容涵盖了所有old apk中资源对应的int值。

那么我们可以这么做,根据这个R.txt,把里面的数据写成public.xml不就能保证原本的资源对应的int值不变了么。

的确是这样的,不过tinker做了更多,不仅将old apk的中的资源信息写到public.xml,而且还干涉了新的资源,对新的资源按照资源id的生成规则,也分配的对应的int值,写到了public.xml,可以说该task包办了资源id的生成。

分析前的总结

好了,由于代码非常长,我决定在这个地方先用总结性的语言总结下,如果没有耐心看代码的可以直接跳过源码分析阶段:

首先将设置的old R.txt读取到内存中,转为:

  • 一个Map,key-value都代表一个具体资源信息;直接复用,不会生成新的资源信息。
  • 一个Map,key为资源类型,value为该类资源当前的最大int值;参与新的资源id的生成。

接下来遍历当前app中的资源,资源分为:

  • values文件夹下文件

对所有values相关文件夹下的文件已经处理完毕,大致的处理为:遍历文件中的节点,大致有item,dimen,color,drawable,bool,integer,array,style,declare-styleable,attr,fraction这些节点,将所有的节点按类型分类存储到rTypeResourceMap(key为资源类型,value为对应类型资源集合Set)中。

其中declare-styleable这个标签,主要读取其内部的attr标签,对attr标签对应的资源按上述处理。

  • res下非values文件夹

打开自己的项目有看一眼,除了values相关还有layout,anim,color等文件夹,主要分为两类:

一类是对 文件 即为资源,例如R.layout.xxx,R.drawable.xxx等;另一类为xml文档中以@+(去除@+钱柜娱乐开户:id),其实就是找到我们自定义id节点,然后截取该节点的id值部分作为属性的名称(例如:@+id/tv,tv即为属性的名称)。

如果和设置的old apk中文件中相同name和type的节点不需要特殊处理,直接复用即可;如果不存在则需要生成新的typeId、resourceId等信息。

会将所有生成的资源都存到rTypeResourceMap中,最后写文件。

这样就基本收集到了所有的需要生成资源信息的所有的资源,最后写到public.xml即可。

总结性的语言难免有一些疏漏,实际以源码分析为标准。

开始源码分析

@TaskAction
def applyResourceId() {
     // 资源mapping文件
    String resourceMappingFile = project.extensions.tinkerPatch.buildConfig.applyResourceMapping

    // resDir /build/intermediates/res/merged/debug
    String idsXml = resDir + "/values/ids.xml";
    String publicXml = resDir + "/values/public.xml";
    FileOperation.deleteFile(idsXml);
    FileOperation.deleteFile(publicXml);

    List<String> resourceDirectoryList = new ArrayList<String>();
    // /build/intermediates/res/merged/debug
    resourceDirectoryList.add(resDir);

    project.logger.error("we build ${project.getName()} apk with apply resource mapping file ${resourceMappingFile}");

    project.extensions.tinkerPatch.buildConfig.usingResourceMapping = true;

    // 收集所有的资源,以type->type,name,id,int/int[]存储
    Map<RDotTxtEntry.RType, Set<RDotTxtEntry>> rTypeResourceMap = PatchUtil.readRTxt(resourceMappingFile);

    AaptResourceCollector aaptResourceCollector = AaptUtil.collectResource(resourceDirectoryList, rTypeResourceMap);

    PatchUtil.generatePublicResourceXml(aaptResourceCollector, idsXml, publicXml);
    File publicFile = new File(publicXml);
    if (publicFile.exists()) {
        FileOperation.copyFileUsingStream(publicFile, project.file(RESOURCE_PUBLIC_XML));
        project.logger.error("tinker gen resource public.xml in ${RESOURCE_PUBLIC_XML}");
    }
    File idxFile = new File(idsXml);
    if (idxFile.exists()) {
        FileOperation.copyFileUsingStream(idxFile, project.file(RESOURCE_IDX_XML));
        project.logger.error("tinker gen resource idx.xml in ${RESOURCE_IDX_XML}");
    }
}

大体浏览下代码,可以看到首先检测是否设置了resource mapping文件,如果没有设置会直接跳过。并且最后的产物是public.xmlids.xml

因为生成patch时,需要保证两次打包已经存在的资源的id一致,需要public.xmlids.xml的参与。

首先清理已经存在的public.xmlids.xml,然后通过PatchUtil.readRTxt读取resourceMappingFile(参数中设置的),该文件记录的格式如下:

int anim abc_slide_in_bottom 0x7f050006
int id useLogo 0x7f0b0012
int[] styleable AppCompatImageView { 0x01010119, 0x7f010027 }
int styleable AppCompatImageView_钱柜娱乐开户_src 0
int styleable AppCompatImageView_srcCompat 1

大概有两类,一类是int型各种资源;一类是int[]数组,代表styleable,其后面紧跟着它的item(熟悉自定义View的一定不陌生)。

PatchUtil.readRTxt的代码就不贴了,简单描述下:

首先正则按行匹配,每行分为四部分,即idType,rType,name,idValue(四个属性为RDotTxtEntry的成员变量)。

  • idType有两种INTINT_ARRAY
  • rType包含各种资源:

ANIM, ANIMATOR, ARRAY, ATTR, BOOL, COLOR, DIMEN, DRAWABLE, FRACTION,
ID, INTEGER, INTERPOLATOR, LAYOUT, MENU, MIPMAP, PLURALS, RAW,
STRING, STYLE, STYLEABLE, TRANSITION, XML

http://developer.钱柜娱乐开户.com/reference/钱柜娱乐开户/R.html

name和value就是普通的键值对了。

这里并没有对styleable做特殊处理。

最后按rType分类,存在一个Map中,即key为rType,value为一个RDotTxtEntry类型的Set集合。

回顾下剩下的代码:

//...省略前半部分
     AaptResourceCollector aaptResourceCollector = AaptUtil.collectResource(resourceDirectoryList, rTypeResourceMap);
    PatchUtil.generatePublicResourceXml(aaptResourceCollector, idsXml, publicXml);
    File publicFile = new File(publicXml);
    if (publicFile.exists()) {
        FileOperation.copyFileUsingStream(publicFile, project.file(RESOURCE_PUBLIC_XML));
        project.logger.error("tinker gen resource public.xml in ${RESOURCE_PUBLIC_XML}");
    }
    File idxFile = new File(idsXml);
    if (idxFile.exists()) {
        FileOperation.copyFileUsingStream(idxFile, project.file(RESOURCE_IDX_XML));
        project.logger.error("tinker gen resource idx.xml in ${RESOURCE_IDX_XML}");
    }

那么到了AaptUtil.collectResource方法,传入了resDir目录和我们刚才收集了资源信息的Map,返回了一个AaptResourceCollector对象,看名称是对aapt相关的资源的收集:

看代码:

public static AaptResourceCollector collectResource(List<String> resourceDirectoryList,
                                                    Map<RType, Set<RDotTxtEntry>> rTypeResourceMap) {
    AaptResourceCollector resourceCollector = new AaptResourceCollector(rTypeResourceMap);
    List<com.tencent.tinker.build.aapt.RDotTxtEntry> references = new ArrayList<com.tencent.tinker.build.aapt.RDotTxtEntry>();
    for (String resourceDirectory : resourceDirectoryList) {
        try {
            collectResources(resourceDirectory, resourceCollector);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    for (String resourceDirectory : resourceDirectoryList) {
        try {
            processXmlFilesForIds(resourceDirectory, references, resourceCollector);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
    return resourceCollector;
}

首先初始化了一个AaptResourceCollector对象,看其构造方法:

public AaptResourceCollector(Map<RType, Set<RDotTxtEntry>> rTypeResourceMap) {
    this();
    if (rTypeResourceMap != null) {
        Iterator<Entry<RType, Set<RDotTxtEntry>>> iterator = rTypeResourceMap.entrySet().iterator();
        while (iterator.hasNext()) {
            Entry<RType, Set<RDotTxtEntry>> entry = iterator.next();
            RType rType = entry.getKey();
            Set<RDotTxtEntry> set = entry.getValue();

            for (RDotTxtEntry rDotTxtEntry : set) {
                originalResourceMap.put(rDotTxtEntry, rDotTxtEntry);

                ResourceIdEnumerator resourceIdEnumerator = null;
                    // ARRAY主要是styleable
                if (!rDotTxtEntry.idType.equals(IdType.INT_ARRAY)) {
                        // 获得resourceId
                    int resourceId = Integer.decode(rDotTxtEntry.idValue.trim()).intValue();
                    // 获得typeId
                    int typeId = ((resourceId & 0x00FF0000) / 0x00010000);


                    if (typeId >= currentTypeId) {
                        currentTypeId = typeId + 1;
                    }

                        // type -> id的映射
                    if (this.rTypeEnumeratorMap.containsKey(rType)) {
                        resourceIdEnumerator = this.rTypeEnumeratorMap.get(rType);
                        if (resourceIdEnumerator.currentId < resourceId) {
                            resourceIdEnumerator.currentId = resourceId;
                        }
                    } else {
                        resourceIdEnumerator = new ResourceIdEnumerator();
                        resourceIdEnumerator.currentId = resourceId;
                        this.rTypeEnumeratorMap.put(rType, resourceIdEnumerator);
                    }
                }
            }
        }
    }
}

对rTypeResourceMap根据rType进行遍历,读取每个rType对应的Set集合;然后遍历每个rDotTxtEntry:

  1. 加入到originalResourceMap,key和value都是rDotTxtEntry对象
  2. 如果是int型资源,首先读取其typeId,并持续更新currentTypeId(保证其为遍历完成后的最大值+1)
  3. 初始化rTypeEnumeratorMap,key为rType,value为ResourceIdEnumerator,且ResourceIdEnumerator中的currentId保存着目前同类资源的最大的resouceId,也就是说rTypeEnumeratorMap中存储了各个rType对应的最大的资源Id。

结束完成构造方法,执行了

  1. 遍历了resourceDirectoryList,目前其中只有一个resDir,然后执行了collectResources方法;
  2. 遍历了resourceDirectoryList,执行了processXmlFilesForIds

分别读代码了:

collectResources

private static void collectResources(String resourceDirectory, AaptResourceCollector resourceCollector) throws Exception {
    File resourceDirectoryFile = new File(resourceDirectory);
    File[] fileArray = resourceDirectoryFile.listFiles();
    if (fileArray != null) {
        for (File file : fileArray) {
            if (file.isDirectory()) {
                String directoryName = file.getName();
                if (directoryName.startsWith("values")) {
                    if (!isAValuesDirectory(directoryName)) {
                        throw new AaptUtilException("'" + directoryName + "' is not a valid values directory.");
                    }
                    processValues(file.getAbsolutePath(), resourceCollector);
                } else {
                    processFileNamesInDirectory(file.getAbsolutePath(), resourceCollector);
                }
            }
        }
    }
}

遍历我们的resDir中的所有文件夹

  • 如果是values相关文件夹,执行processValues
  • 非values相关文件夹则执行processFileNamesInDirectory

processValues处理values相关文件,会遍历每一个合法的values相关文件夹下的文件,执行processValuesFile(file.getAbsolutePath(), resourceCollector);

public static void processValuesFile(String valuesFullFilename,
                                     AaptResourceCollector resourceCollector) throws Exception {
    Document document = JavaXmlUtil.parse(valuesFullFilename);
    String directoryName = new File(valuesFullFilename).getParentFile().getName();
    Element root = document.getDocumentElement();

    for (Node node = root.getFirstChild(); node != null; node = node.getNextSibling()) {
        if (node.getNodeType() != Node.ELEMENT_NODE) {
            continue;
        }

        String resourceType = node.getNodeName();
        if (resourceType.equals(ITEM_TAG)) {
            resourceType = node.getAttributes().getNamedItem("type").getNodeValue();
            if (resourceType.equals("id")) {
                resourceCollector.addIgnoreId(node.getAttributes().getNamedItem("name").getNodeValue());
            }
        }

        if (IGNORED_TAGS.contains(resourceType)) {
            continue;
        }

        if (!RESOURCE_TYPES.containsKey(resourceType)) {
            throw new AaptUtilException("Invalid resource type '<" + resourceType + ">' in '" + valuesFullFilename + "'.");
        }

        RType rType = RESOURCE_TYPES.get(resourceType);
        String resourceValue = null;
        switch (rType) {
            case STRING:
            case COLOR:
            case DIMEN:
            case DRAWABLE:
            case BOOL:
            case INTEGER:
                resourceValue = node.getTextContent().trim();
                break;
            case ARRAY://has sub item
            case PLURALS://has sub item
            case STYLE://has sub item
            case STYLEABLE://has sub item
                resourceValue = subNodeToString(node);
                break;
            case FRACTION://no sub item
                resourceValue = nodeToString(node, true);
                break;
            case ATTR://no sub item
                resourceValue = nodeToString(node, true);
                break;
        }
        try {
            addToResourceCollector(resourceCollector,
                    new ResourceDirectory(directoryName, valuesFullFilename),
                    node, rType, resourceValue);
        } catch (Exception e) {
            throw new AaptUtilException(e.getMessage() + ",Process file error:" + valuesFullFilename, e);
        }
    }
}

values下相关的文件基本都是xml咯,所以遍历xml文件,遍历其内部的节点,(values的xml文件其内部一般为item,dimen,color,drawable,bool,integer,array,style,declare-styleable,attr,fraction等),每种类型的节点对应一个rType,根据不同类型的节点也会去获取节点的值,确定一个都会执行:

addToResourceCollector(resourceCollector,
    new ResourceDirectory(directoryName, valuesFullFilename),
    node, rType, resourceValue);

注:除此以外,这里在ignoreIdSet记录了声明的id资源,这些id是已经声明过的,所以最终在编写ids.xml时,可以过滤掉这些id。

下面继续看:addToResourceCollector

源码如下:

private static void addToResourceCollector(AaptResourceCollector resourceCollector,
                                           ResourceDirectory resourceDirectory,
                                           Node node, RType rType, String resourceValue) {
    String resourceName = sanitizeName(rType, resourceCollector, extractNameAttribute(node));

    if (rType.equals(RType.STYLEABLE)) {

        int count = 0;
        for (Node attrNode = node.getFirstChild(); attrNode != null; attrNode = attrNode.getNextSibling()) {
            if (attrNode.getNodeType() != Node.ELEMENT_NODE || !attrNode.getNodeName().equals("attr")) {
                continue;
            }
            String rawAttrName = extractNameAttribute(attrNode);
            String attrName = sanitizeName(rType, resourceCollector, rawAttrName);

            if (!rawAttrName.startsWith("钱柜娱乐开户:")) {
                resourceCollector.addIntResourceIfNotPresent(RType.ATTR, attrName);
            }
        }
    } else {
        resourceCollector.addIntResourceIfNotPresent(rType, resourceName);
    }
}

如果不是styleable的资源,则直接获取resourceName,然后调用resourceCollector.addIntResourceIfNotPresent(rType, resourceName)。

如果是styleable类型的资源,则会遍历找到其内部的attr节点,找出非钱柜娱乐开户:开头的(因为钱柜娱乐开户:开头的attr的id不需要我们去确定),设置rType为ATTR,value为attr属性的name,调用addIntResourceIfNotPresent。

public void addIntResourceIfNotPresent(RType rType, String name) { //, ResourceDirectory resourceDirectory) {
    if (!rTypeEnumeratorMap.containsKey(rType)) {
        if (rType.equals(RType.ATTR)) {
            rTypeEnumeratorMap.put(rType, new ResourceIdEnumerator(1));
        } else {
            rTypeEnumeratorMap.put(rType, new ResourceIdEnumerator(currentTypeId++));
        }
    }

    RDotTxtEntry entry = new FakeRDotTxtEntry(IdType.INT, rType, name);
    Set<RDotTxtEntry> resourceSet = null;
    if (this.rTypeResourceMap.containsKey(rType)) {
        resourceSet = this.rTypeResourceMap.get(rType);
    } else {
        resourceSet = new HashSet<RDotTxtEntry>();
        this.rTypeResourceMap.put(rType, resourceSet);
    }
    if (!resourceSet.contains(entry)) {
        String idValue = String.format("0x%08x", rTypeEnumeratorMap.get(rType).next());
        addResource(rType, IdType.INT, name, idValue); //, resourceDirectory);
    }
}

首先构建一个entry,然后判断当前的rTypeResourceMap中是否存在该资源实体,如果存在,则什么都不用做。

如果不存在,则需要构建一个entry,那么主要是id的构建。

关于id的构建:

还记得rTypeEnumeratorMap么,其内部包含了我们设置的”res mapping”文件,存储了每一类资源(rType)的资源的最大resourceId值。

那么首先判断就是是否已经有这种类型了,如果有的话,获取出该类型当前最大的resourceId,然后+1,最为传入资源的resourceId.

如果不存在当前这种类型,那么如果类型为ATTR则固定type为1;否则的话,新增一个typeId,为当前最大的type+1(currentTypeId中也是记录了目前最大的type值),有了类型就可以通过ResourceIdEnumerator.next()来获取id。

经过上述就可以构造出一个idValue了。

最后调用:

addResource(rType, IdType.INT, name, idValue);

查看代码:

public void addResource(RType rType, IdType idType, String name, String idValue) {
    Set<RDotTxtEntry> resourceSet = null;
    if (this.rTypeResourceMap.containsKey(rType)) {
        resourceSet = this.rTypeResourceMap.get(rType);
    } else {
        resourceSet = new HashSet<RDotTxtEntry>();
        this.rTypeResourceMap.put(rType, resourceSet);
    }
    RDotTxtEntry rDotTxtEntry = new RDotTxtEntry(idType, rType, name, idValue);

    if (!resourceSet.contains(rDotTxtEntry)) {
        if (this.originalResourceMap.containsKey(rDotTxtEntry)) {
            this.rTypeEnumeratorMap.get(rType).previous();
            rDotTxtEntry = this.originalResourceMap.get(rDotTxtEntry);
        } 
        resourceSet.add(rDotTxtEntry);
    }

}

大体意思就是如果该资源不存在就添加到rTypeResourceMap。

首先构建出该资源实体,判断该类型对应的资源集合是否包含该资源实体(这里contains只比对name和type),如果不包含,判断是否在originalResourceMap中,如果存在(这里做了一个previous操作,其实与上面的代码的next操作对应,主要是针对资源存在我们的res map中这种情况)则取出该资源实体,最终将该资源实体加入到rTypeResourceMap中。

ok,到这里需要小节一下,我们刚才对所有values相关文件夹下的文件已经处理完毕,大致的处理为:遍历文件中的节点,大致有item,dimen,color,drawable,bool,integer,array,style,declare-styleable,attr,fraction这些节点,将所有的节点按类型分类存储到rTypeResourceMap中(如果和设置的”res map”文件中相同name和type的节点不需要特殊处理,直接复用即可;如果不存在则需要生成新的typeId、resourceId等信息)。

其中declare-styleable这个标签,主要读取其内部的attr标签,对attr标签对应的资源按上述处理。

处理完成values相关文件夹之后,还需要处理一些res下的其他文件,比如layout、layout、anim等文件夹,该类资源也需要在R中生成对应的id值,这类值也需要固化。

processFileNamesInDirectory

public static void processFileNamesInDirectory(String resourceDirectory,
                                               AaptResourceCollector resourceCollector) throws IOException {
    File resourceDirectoryFile = new File(resourceDirectory);
    String directoryName = resourceDirectoryFile.getName();
    int dashIndex = directoryName.indexOf('-');
    if (dashIndex != -1) {
        directoryName = directoryName.substring(0, dashIndex);
    }

    if (!RESOURCE_TYPES.containsKey(directoryName)) {
        throw new AaptUtilException(resourceDirectoryFile.getAbsolutePath() + " is not a valid resource sub-directory.");
    }
    File[] fileArray = resourceDirectoryFile.listFiles();
    if (fileArray != null) {
        for (File file : fileArray) {
            if (file.isHidden()) {
                continue;
            }
            String filename = file.getName();
            int dotIndex = filename.indexOf('.');
            String resourceName = dotIndex != -1 ? filename.substring(0, dotIndex) : filename;

            RType rType = RESOURCE_TYPES.get(directoryName);
            resourceCollector.addIntResourceIfNotPresent(rType, resourceName);

            System.out.println("rType = " + rType + " , resName = " + resourceName);

            ResourceDirectory resourceDirectoryBean = new ResourceDirectory(file.getParentFile().getName(), file.getAbsolutePath());
            resourceCollector.addRTypeResourceName(rType, resourceName, null, resourceDirectoryBean);
        }
    }
}

遍历res下所有文件夹,根据文件夹名称确定其对应的资源类型(例如:drawable-xhpi,则认为其内部的文件类型为drawable类型),然后遍历该文件夹下所有的文件,最终以文件名为资源的name,文件夹确定资源的type,最终调用:

resourceCollector
.addIntResourceIfNotPresent(rType, resourceName);

processXmlFilesForIds

public static void processXmlFilesForIds(String resourceDirectory,
                                         List<RDotTxtEntry> references, AaptResourceCollector resourceCollector) throws Exception {
    List<String> xmlFullFilenameList = FileUtil
            .findMatchFile(resourceDirectory, Constant.Symbol.DOT + Constant.File.XML);
    if (xmlFullFilenameList != null) {
        for (String xmlFullFilename : xmlFullFilenameList) {
            File xmlFile = new File(xmlFullFilename);

            String parentFullFilename = xmlFile.getParent();
            File parentFile = new File(parentFullFilename);
            if (isAValuesDirectory(parentFile.getName()) || parentFile.getName().startsWith("raw")) {
                // Ignore files under values* directories and raw*.
                continue;
            }
            processXmlFile(xmlFullFilename, references, resourceCollector);
        }
    }
}

遍历除了raw*以及values*相关文件夹下的xml文件,执行processXmlFile。

public static void processXmlFile(String xmlFullFilename, List<RDotTxtEntry> references, AaptResourceCollector resourceCollector)
        throws IOException, XPathExpressionException {
    Document document = JavaXmlUtil.parse(xmlFullFilename);
    NodeList nodesWithIds = (NodeList) 钱柜娱乐开户_ID_DEFINITION.evaluate(document, XPathConstants.NODESET);
    for (int i = 0; i < nodesWithIds.getLength(); i++) {
        String resourceName = nodesWithIds.item(i).getNodeValue();


        if (!resourceName.startsWith(ID_DEFINITION_PREFIX)) {
            throw new AaptUtilException("Invalid definition of a resource: '" + resourceName + "'");
        }

        resourceCollector.addIntResourceIfNotPresent(RType.ID, resourceName.substring(ID_DEFINITION_PREFIX.length()));
    }

    // 省略了无关代码
}

主要找xml文档中以@+(去除@+钱柜娱乐开户:id),其实就是找到我们自定义id节点,然后截取该节点的id值部分作为属性的名称(例如:@+id/tv,tv即为属性的名称),最终调用:

resourceCollector
    .addIntResourceIfNotPresent(RType.ID, 
        resourceName.substring(ID_DEFINITION_PREFIX.length()));

上述就完成了所有的资源的收集,那么剩下的就是写文件了:


public static void generatePublicResourceXml(AaptResourceCollector aaptResourceCollector,
                                             String outputIdsXmlFullFilename,
                                             String outputPublicXmlFullFilename) {
    if (aaptResourceCollector == null) {
        return;
    }
    FileUtil.createFile(outputIdsXmlFullFilename);
    FileUtil.createFile(outputPublicXmlFullFilename);

    PrintWriter idsWriter = null;
    PrintWriter publicWriter = null;
    try {
        FileUtil.createFile(outputIdsXmlFullFilename);
        FileUtil.createFile(outputPublicXmlFullFilename);
        idsWriter = new PrintWriter(new File(outputIdsXmlFullFilename), "UTF-8");

        publicWriter = new PrintWriter(new File(outputPublicXmlFullFilename), "UTF-8");
        idsWriter.println("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
        publicWriter.println("<?xml version=\"1.0\" encoding=\"utf-8\"?>");
        idsWriter.println("<resources>");
        publicWriter.println("<resources>");
        Map<RType, Set<RDotTxtEntry>> map = aaptResourceCollector.getRTypeResourceMap();
        Iterator<Entry<RType, Set<RDotTxtEntry>>> iterator = map.entrySet().iterator();
        while (iterator.hasNext()) {
            Entry<RType, Set<RDotTxtEntry>> entry = iterator.next();
            RType rType = entry.getKey();
            if (!rType.equals(RType.STYLEABLE)) {
                Set<RDotTxtEntry> set = entry.getValue();
                for (RDotTxtEntry rDotTxtEntry : set) {
                    String rawName = aaptResourceCollector.getRawName(rType, rDotTxtEntry.name);
                    if (StringUtil.isBlank(rawName)) {
                        rawName = rDotTxtEntry.name;
                    }
                    publicWriter.println("<public type=\"" + rType + "\" name=\"" + rawName + "\" id=\"" + rDotTxtEntry.idValue.trim() + "\" />");          
                }
                Set<String> ignoreIdSet = aaptResourceCollector.getIgnoreIdSet();
                for (RDotTxtEntry rDotTxtEntry : set) {
                    if (rType.equals(RType.ID) && !ignoreIdSet.contains(rDotTxtEntry.name)) {
                        idsWriter.println("<item type=\"" + rType + "\" name=\"" + rDotTxtEntry.name + "\"/>");
                    } 
                }
            }
            idsWriter.flush();
            publicWriter.flush();
        }
        idsWriter.println("</resources>");
        publicWriter.println("</resources>");
    } catch (Exception e) {
        throw new PatchUtilException(e);
    } finally {
        if (idsWriter != null) {
            idsWriter.flush();
            idsWriter.close();
        }
        if (publicWriter != null) {
            publicWriter.flush();
            publicWriter.close();
        }
    }
}

主要就是遍历rTypeResourceMap,然后每个资源实体对应一条public标签记录写到public.xml中。

此外,如果发现该元素节点的type为Id,并且不在ignoreSet中,会写到ids.xml这个文件中。(这里有个ignoreSet,这里ignoreSet中记录了values下所有的<item type=id的资源,是直接在项目中已经声明过的,所以去除)。

六、TinkerProguardConfigTask

还记得文初说:

  1. 我们在上线app的时候,会做代码混淆,如果没有做特殊的设置,每次混淆后的代码差别应该非常巨大;所以,build过程中理论上需要设置混淆的mapping文件。
  2. 在接入一些库的时候,往往还需要配置混淆,比如第三方库中哪些东西不能被混淆等(当然强制某些类在主dex中,也可能需要配置相对应的混淆规则)。

这个task的作用很明显了。有时候为了确保一些类在main dex中,简单的做法也会对其在混淆配置中进行keep(避免由于混淆造成类名更改,而使main dex的keep失效)。

如果开启了proguard会执行该task。

这个就是主要去设置混淆的mapping文件,和keep一些必要的类了。

@TaskAction
def updateTinkerProguardConfig() {
    def file = project.file(PROGUARD_CONFIG_PATH)
    project.logger.error("try update tinker proguard file with ${file}")

    // Create the directory if it doesnt exist already
    file.getParentFile().mkdirs()

    // Write our recommended proguard settings to this file
    FileWriter fr = new FileWriter(file.path)

    String applyMappingFile = project.extensions.tinkerPatch.buildConfig.applyMapping

    //write applymapping
    if (shouldApplyMapping && FileOperation.isLegalFile(applyMappingFile)) {
        project.logger.error("try add applymapping ${applyMappingFile} to build the package")
        fr.write("-applymapping " + applyMappingFile)
        fr.write("\n")
    } else {
        project.logger.error("applymapping file ${applyMappingFile} is illegal, just ignore")
    }

    fr.write(PROGUARD_CONFIG_SETTINGS)

    fr.write("#your dex.loader patterns here\n")
    //they will removed when apply
    Iterable<String> loader = project.extensions.tinkerPatch.dex.loader
    for (String pattern : loader) {
        if (pattern.endsWith("*") && !pattern.endsWith("**")) {
            pattern += "*"
        }
        fr.write("-keep class " + pattern)
        fr.write("\n")
    }
    fr.close()
    // Add this proguard settings file to the list
    applicationVariant.getBuildType().buildType.proguardFiles(file)
    def files = applicationVariant.getBuildType().buildType.getProguardFiles()

    project.logger.error("now proguard files is ${files}")
}

读取我们设置的mappingFile,设置

-applymapping applyMappingFile

然后设置一些默认需要keep的规则:

PROGUARD_CONFIG_SETTINGS =
"-keepattributes *Annotation* \n" +
"-dontwarn com.tencent.tinker.anno.AnnotationProcessor \n" +
"-keep @com.tencent.tinker.anno.DefaultLifeCycle public class *\n" +
"-keep public class * extends 钱柜娱乐开户.app.Application {\n" +
"    *;\n" +
"}\n" +
"\n" +
"-keep public class com.tencent.tinker.loader.app.ApplicationLifeCycle {\n" +
"    *;\n" +
"}\n" +
"-keep public class * implements com.tencent.tinker.loader.app.ApplicationLifeCycle {\n" +
"    *;\n" +
"}\n" +
"\n" +
"-keep public class com.tencent.tinker.loader.TinkerLoader {\n" +
"    *;\n" +
"}\n" +
"-keep public class * extends com.tencent.tinker.loader.TinkerLoader {\n" +
"    *;\n" +
"}\n" +
"-keep public class com.tencent.tinker.loader.TinkerTestDexLoad {\n" +
"    *;\n" +
"}\n" +
"\n"

最后是keep住我们的application、com.tencent.tinker.loader.**以及我们设置的相关类。

TinkerManifestTask中:addApplicationToLoaderPattern主要是记录自己的application类名和tinker相关的一些load class com.tencent.tinker.loader.*,记录在project.extensions.tinkerPatch.dex.loader

七、TinkerMultidexConfigTask

对应文初:

当项目比较大的时候,我们可能会遇到方法数超过65535的问题,我们很多时候会通过分包解决,这样就有主dex和其他dex的概念。集成了tinker之后,在应用的Application启动时会非常早的就去做tinker的load操作,所以就决定了load相关的类必须在主dex中。

如果multiDexEnabled开启。

主要是让相关类必须在main dex。

"-keep public class * implements com.tencent.tinker.loader.app.ApplicationLifeCycle {\n" +
    "    *;\n" +
    "}\n" +
    "\n" +
    "-keep public class * extends com.tencent.tinker.loader.TinkerLoader {\n" +
    "    *;\n" +
    "}\n" +
    "\n" +
    "-keep public class * extends 钱柜娱乐开户.app.Application {\n" +
    "    *;\n" +
    "}\n"
Iterable<String> loader = project.extensions.tinkerPatch.dex.loader
    for (String pattern : loader) {
        if (pattern.endsWith("*")) {
            if (!pattern.endsWith("**")) {
                pattern += "*"
            }
        }
        lines.append("-keep class " + pattern + " {\n" +
                "    *;\n" +
                "}\n")
                .append("\n")
    }

相关类都在loader这个集合中,在TinkerManifestTask中设置的。

八、TinkerPatchSchemaTask

主要执行Runner.tinkerPatch

protected void tinkerPatch() {
    try {
        //gen patch
        ApkDecoder decoder = new ApkDecoder(config);
        decoder.onAllPatchesStart();
        decoder.patch(config.mOldApkFile, config.mNewApkFile);
        decoder.onAllPatchesEnd();

        //gen meta file and version file
        PatchInfo info = new PatchInfo(config);
        info.gen();

        //build patch
        PatchBuilder builder = new PatchBuilder(config);
        builder.buildPatch();

    } catch (Throwable e) {
        e.printStackTrace();
        goToError();
    }
}

主要分为以下环节:

  • 生成patch
  • 生成meta-file和version-file,这里主要就是在assets目录下写一些键值对。(包含tinkerId以及配置中configField相关信息)
  • build patch

(1)生成pacth

顾名思义就是两个apk比较去生成各类patch文件,那么从一个apk的组成来看,大致可以分为:

  • dex文件比对的patch文件
  • res文件比对的patch res文件
  • so文件比对生成的so patch文件

看下代码:

public boolean patch(File oldFile, File newFile) throws Exception {
    //check manifest change first
    manifestDecoder.patch(oldFile, newFile);

    unzipApkFiles(oldFile, newFile);

    Files.walkFileTree(mNewApkDir.toPath(), new ApkFilesVisitor(config, mNewApkDir.toPath(),
            mOldApkDir.toPath(), dexPatchDecoder, soPatchDecoder, resPatchDecoder));

    soPatchDecoder.onAllPatchesEnd();
    dexPatchDecoder.onAllPatchesEnd();
    manifestDecoder.onAllPatchesEnd();
    resPatchDecoder.onAllPatchesEnd();

    //clean resources
    dexPatchDecoder.clean();
    soPatchDecoder.clean();
    resPatchDecoder.clean();
    return true;
}

代码内部包含四个Decoder:

  • manifestDecoder
  • dexPatchDecoder
  • soPatchDecoder
  • resPatchDecoder

刚才提到需要对dex、so、res文件做diff,但是为啥会有个manifestDecoder。目前tinker并不支持四大组件,也就是说manifest文件中是不允许出现新增组件的。

所以,manifestDecoder的作用实际上是用于检查的:

  1. minSdkVersion<14时仅允许dexMode使用jar模式(TODO:raw模式的区别是什么?)
  2. 会解析manifest文件,读取出组大组件进行对比,不允许出现新增的任何组件。

代码就不贴了非常好理解,关于manifest的解析是基于该库封装的:

https://github.com/clearthesky/apk-parser

然后就是解压两个apk文件了,old apk(我们设置的),old apk 生成的。

解压的目录为:

  • old apk: build/intermediates/outputs/old apk名称/
  • new apk: build/intermediates/outputs/app-debug/

解压完成后,就是单个文件对比了:

对比的思路是,以newApk解压目录下所有的文件为基准,去oldApk中找同名的文件,那么会有以下几个情况:

  1. 在oldApkDir中没有找到,那么说明该文件是新增的
  2. 在oldApkDir中找到了,那么比对md5,如果不同,则认为改变了(则需要根据情况做diff)

有了大致的了解后,可以看代码:

Files.walkFileTree(
    mNewApkDir.toPath(), 
    new ApkFilesVisitor(
        config, 
        mNewApkDir.toPath(),
        mOldApkDir.toPath(), 
        dexPatchDecoder, 
        soPatchDecoder, 
        resPatchDecoder));

Files.walkFileTree会以mNewApkDir.toPath()为基准,遍历其内部所有的文件,ApkFilesVisitor中可以对每个遍历的文件进行操作。

重点看ApkFilesVisitor是如何操作每个文件的:

@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs) throws IOException {

    Path relativePath = newApkPath.relativize(file);
    // 在oldApkDir中找到该文件
    Path oldPath = oldApkPath.resolve(relativePath);

    File oldFile = null;
    //is a new file?!
    if (oldPath.toFile().exists()) {
        oldFile = oldPath.toFile();
    }

    String patternKey = relativePath.toString().replace("\\", "/");

    if (Utils.checkFileInPattern(config.mDexFilePattern, patternKey)) {
        dexDecoder.patch(oldFile, file.toFile());
    }
    if (Utils.checkFileInPattern(config.mSoFilePattern, patternKey)) {
        soDecoder.patch(oldFile, file.toFile());
    }
    if (Utils.checkFileInPattern(config.mResFilePattern, patternKey)) {
         resDecoder.patch(oldFile, file.toFile());
    }
    return FileVisitResult.CONTINUE;
}

首先去除newApkDir中的一个文件,在oldApkDir中寻找同名的apk;然后根据名称判断该文件属于:

  1. dexFile -> dexDecoder.patch 完成dex文件间的比对
  2. soFile -> soDecoder.patch 完成so文件的比对
  3. resFile -> resDecoder.patch 完成res文件的比对

各种文件的规则是可配置的。

(1)dexDecoder.patch

public boolean patch(final File oldFile, final File newFile)  {
    final String dexName = getRelativeDexName(oldFile, newFile);

    // 检查loader class,省略了抛异常的一些代码
    excludedClassModifiedChecker.checkIfExcludedClassWasModifiedInNewDex(oldFile, newFile);


    File dexDiffOut = getOutputPath(newFile).toFile();

    final String newMd5 = getRawOrWrappedDexMD5(newFile);

    //new add file
    if (oldFile == null || !oldFile.exists() || oldFile.length() == 0) {
        hasDexChanged = true;
        copyNewDexAndLogToDexMeta(newFile, newMd5, dexDiffOut);
        return true;
    }

    final String oldMd5 = getRawOrWrappedDexMD5(oldFile);

    if ((oldMd5 != null && !oldMd5.equals(newMd5)) || (oldMd5 == null && newMd5 != null)) {
        hasDexChanged = true;
        if (oldMd5 != null) {
            collectAddedOrDeletedClasses(oldFile, newFile);
        }
    }

    RelatedInfo relatedInfo = new RelatedInfo();
    relatedInfo.oldMd5 = oldMd5;
    relatedInfo.newMd5 = newMd5;

    // collect current old dex file and corresponding new dex file for further processing.
    oldAndNewDexFilePairList.add(new AbstractMap.SimpleEntry<>(oldFile, newFile));

    dexNameToRelatedInfoMap.put(dexName, relatedInfo);

    return true;
}

首先执行:

checkIfExcludedClassWasModifiedInNewDex(oldFile, newFile);

该方法主要用处是检查 tinker loader相关classes**必须存在primary dex中**,且不允许新增、修改和删除。

所有首先将两个dex读取到内存中,按照config.mDexLoaderPattern进行过滤,找出deletedClassInfosaddedClassInfoschangedClassInfosMap,必须保证deletedClassInfos.isEmpty() && addedClassInfos.isEmpty() && changedClassInfosMap.isEmpty()即不允许新增、删除、修改loader 相关类。

继续,拿到输出目录:

  • build/intermediates/outputs/tinker_result/

然后如果oldFile不存在,则newFile认为是新增文件,直接copy到输出目录,并记录log

copyNewDexAndLogToDexMeta(newFile, newMd5, dexDiffOut);

如果存在,则计算两个文件的md5,如果md5不同,则认为dexChanged(hasDexChanged = true),执行:

collectAddedOrDeletedClasses(oldFile, newFile);

该方法收集了addClasses和deleteClasses的相关信息,记录在:

  • addedClassDescToDexNameMap key为addClassDesc 和 该dex file的path
  • deletedClassDescToDexNameMap key为deletedClassDesc 和 该dex file的path

后续会使用这两个数据结构,mark一下。

继续往下走,初始化了一个relatedInfo记录了两个文件的md5,以及在oldAndNewDexFilePairList中记录了两个dex file,在dexNameToRelatedInfoMap中记录了dexName和relatedInfo的映射。

后续会使用该变量,mark一下。

到此,dexDecoder的patch方法就结束了,仅将新增的文件copy到了目标目录。

那么发生改变的文件,理论上应该要做md5看来在后面才会执行。

如果文件是so文件,则会走soDecoder.patch。

(2)soDecoder.patch

soDecoder实际上是BsDiffDecoder

@Override
public boolean patch(File oldFile, File newFile)  {
    //new add file
    String newMd5 = MD5.getMD5(newFile);
    File bsDiffFile = getOutputPath(newFile).toFile();

    if (oldFile == null || !oldFile.exists()) {
        FileOperation.copyFileUsingStream(newFile, bsDiffFile);
        writeLogFiles(newFile, null, null, newMd5);
        return true;
    }

    //new add file
    String oldMd5 = MD5.getMD5(oldFile);

    if (oldMd5.equals(newMd5)) {
        return false;
    }

    if (!bsDiffFile.getParentFile().exists()) {
        bsDiffFile.getParentFile().mkdirs();
    }
    BSDiff.bsdiff(oldFile, newFile, bsDiffFile);

    //超过80%,返回false
    if (Utils.checkBsDiffFileSize(bsDiffFile, newFile)) {
        writeLogFiles(newFile, oldFile, bsDiffFile, newMd5);
    } else {
        FileOperation.copyFileUsingStream(newFile, bsDiffFile);
        writeLogFiles(newFile, null, null, newMd5);
    }
    return true;
}

如果oldFile不存在,则认为newFile为新增文件,直接copy到目标文件(连着so相关目录)。

若oldFile存在,则比对二者md5,如果md5不一致,则直接进行bsdiff算法,直接在目标位置写入bsdiff产生的bsDiffFile。

本来到此应该已经结束了,但是接下来做了一件挺有意思的事:

继续判断了生成的patch文件是否已经超过newFile的80%,如果超过80%,则直接copy newFile到目标目录,直接覆盖了刚生成的patch文件。

那么soPatch整个过程:

  1. 如果是新增文件,直接copy至目标文件夹,记录log
  2. 如果是改变的文件,patch文件超过新文件的80%,则直接copy新文件至目标文件夹,记录log
  3. 如果是改变的文件,patch文件不超过新文件的80%,则copy patch文件至目标文件夹,记录log

如果newFile是res 资源,则会走resDecoder

(3)resDecoder.patch

@Override
public boolean patch(File oldFile, File newFile) throws IOException, TinkerPatchException {
    String name = getRelativePathStringToNewFile(newFile);

    File outputFile = getOutputPath(newFile).toFile();

    if (oldFile == null || !oldFile.exists()) {
        FileOperation.copyFileUsingStream(newFile, outputFile);
        addedSet.add(name);
        writeResLog(newFile, oldFile, TypedValue.ADD);
        return true;
    }

    //new add file
    String newMd5 = MD5.getMD5(newFile);
    String oldMd5 = MD5.getMD5(oldFile);

    //oldFile or newFile may be 0b length
    if (oldMd5 != null && oldMd5.equals(newMd5)) {
        return false;
    }
    if (Utils.checkFileInPattern(config.mResIgnoreChangePattern, name)) {
        Logger.d("found modify resource: " + name + ", but it match ignore change pattern, just ignore!");
        return false;
    }
    if (name.equals(TypedValue.RES_MANIFEST)) {
        Logger.d("found modify resource: " + name + ", but it is 钱柜娱乐开户Manifest.xml, just ignore!");
        return false;
    }
    if (name.equals(TypedValue.RES_ARSC)) {
        if (钱柜娱乐开户Parser.resourceTableLogicalChange(config)) {
            Logger.d("found modify resource: " + name + ", but it is logically the same as original new resources.arsc, just ignore!");
            return false;
        }
    }
    dealWithModeFile(name, newMd5, oldFile, newFile, outputFile);
    return true;
}

如果oldFile不存在,则认为新增文件,直接copy且加入到addedSet集合,并记录log

如果存在,且md5不同调研dealWithModeFile(设置的sIgnoreChangePattern、MANIFEST和逻辑上相同的ARSC不做处理)。


private boolean dealWithModeFile(String name, String newMd5, File oldFile, File newFile, File outputFile) {
    if (checkLargeModFile(newFile)) {
        if (!outputFile.getParentFile().exists()) {
            outputFile.getParentFile().mkdirs();
        }
        BSDiff.bsdiff(oldFile, newFile, outputFile);
        //未超过80%返回true
        if (Utils.checkBsDiffFileSize(outputFile, newFile)) {
            LargeModeInfo largeModeInfo = new LargeModeInfo();
            largeModeInfo.path = newFile;
            largeModeInfo.crc = FileOperation.getFileCrc32(newFile);
            largeModeInfo.md5 = newMd5;
            largeModifiedSet.add(name);
            largeModifiedMap.put(name, largeModeInfo);
            writeResLog(newFile, oldFile, TypedValue.LARGE_MOD);
            return true;
        }
    }
    modifiedSet.add(name);
    FileOperation.copyFileUsingStream(newFile, outputFile);
    writeResLog(newFile, oldFile, TypedValue.MOD);
    return false;
}

这里,首先check了largeFile,即改变的文件是否大于100K(该值可以配置)。

如果非大文件,则直接copy至目标文件,且记录到modifiedSet,并记录了log。

如果是大文件,则直接bsdiff,生成patch File;接下来也检查了一下patch file是否超过newFile的80%,如果超过,则直接copy newFile覆盖刚生成的patch File;

总体和so patch基本一致。

到这里,除了dex patch中对改变的dex文件没有做处理以外,so 和 res都做了。

接下来执行了:


public boolean patch(File oldFile, File newFile) throws Exception {
    //...

    soPatchDecoder.onAllPatchesEnd();
    dexPatchDecoder.onAllPatchesEnd();
    manifestDecoder.onAllPatchesEnd();
    resPatchDecoder.onAllPatchesEnd();

    //clean resources
    dexPatchDecoder.clean();
    soPatchDecoder.clean();
    resPatchDecoder.clean();
    return true;
}

其中dexPatchDecoder和resPatchDecoder有后续实现。

(4) dexPatchDecoder.onAllPatchesEnd

# DexDiffDecoder
@Override
public void onAllPatchesEnd() throws Exception {
    if (!hasDexChanged) {
        Logger.d("No dexes were changed, nothing needs to be done next.");
        return;
    }

    generatePatchInfoFile();

    addTestDex();
}

如果dex文件没有改变,直接返回。

private void generatePatchInfoFile() throws IOException {
    generatePatchedDexInfoFile();

    logDexesToDexMeta();

    checkCrossDexMovingClasses();
}

主要看generatePatchedDexInfoFile


private void generatePatchedDexInfoFile() {
    // Generate dex diff out and full patched dex if a pair of dex is different.
    for (AbstractMap.SimpleEntry<File, File> oldAndNewDexFilePair : oldAndNewDexFilePairList) {
        File oldFile = oldAndNewDexFilePair.getKey();
        File newFile = oldAndNewDexFilePair.getValue();
        final String dexName = getRelativeDexName(oldFile, newFile);
        RelatedInfo relatedInfo = dexNameToRelatedInfoMap.get(dexName);
        if (!relatedInfo.oldMd5.equals(relatedInfo.newMd5)) {
            diffDexPairAndFillRelatedInfo(oldFile, newFile, relatedInfo);
        } else {
            // In this case newDexFile is the same as oldDexFile, but we still
            // need to treat it as patched dex file so that the SmallPatchGenerator
            // can analyze which class of this dex should be kept in small patch.
            relatedInfo.newOrFullPatchedFile = newFile;
            relatedInfo.newOrFullPatchedMd5 = relatedInfo.newMd5;
        }
    }
}

oldAndNewDexFilePairList中记录了两个dex文件,然后根据dex file获取到dexName,再由dexNameToRelatedInfoMap根据name获得到RelatedInfo。

RelatedInfo中包含了两个dex file的md5,如果不同,则执行diffDexPairAndFillRelatedInfo

private void diffDexPairAndFillRelatedInfo(File oldDexFile, 
                        File newDexFile, RelatedInfo relatedInfo) {
    //outputs/tempPatchedDexes
    File tempFullPatchDexPath = new File(config.mOutFolder 
                + File.separator + TypedValue.DEX_TEMP_PATCH_DIR);
    final String dexName = getRelativeDexName(oldDexFile, newDexFile);

    File dexDiffOut = getOutputPath(newDexFile).toFile();
    ensureDirectoryExist(dexDiffOut.getParentFile());


    // dex diff , 去除loader classes
    DexPatchGenerator dexPatchGen = new DexPatchGenerator(oldDexFile, newDexFile);
    dexPatchGen.setAdditionalRemovingClassPatterns(config.mDexLoaderPattern);

    dexPatchGen.executeAndSaveTo(dexDiffOut);


    relatedInfo.dexDiffFile = dexDiffOut;
    relatedInfo.dexDiffMd5 = MD5.getMD5(dexDiffOut);

    File tempFullPatchedDexFile = new File(tempFullPatchDexPath, dexName);

    try {
        new DexPatchApplier(oldDexFile, dexDiffOut).executeAndSaveTo(tempFullPatchedDexFile);

        Logger.d(
                String.format("Verifying if patched new dex is logically the same as original new dex: %s ...", getRelativeStringBy(newDexFile, config.mTempUnzipNewDir))
        );

        Dex origNewDex = new Dex(newDexFile);
        Dex patchedNewDex = new Dex(tempFullPatchedDexFile);
        checkDexChange(origNewDex, patchedNewDex);

        relatedInfo.newOrFullPatchedFile = tempFullPatchedDexFile;
        relatedInfo.newOrFullPatchedMd5 = MD5.getMD5(tempFullPatchedDexFile);
    } catch (Exception e) {
        e.printStackTrace();
        throw new TinkerPatchException(
                "Failed to generate temporary patched dex, which makes MD5 generating procedure of new dex failed, either.", e
        );
    }

    if (!tempFullPatchedDexFile.exists()) {
        throw new TinkerPatchException("can not find the temporary full patched dex file:" + tempFullPatchedDexFile.getAbsolutePath());
    }
    Logger.d("\nGen %s for dalvik full dex file:%s, size:%d, md5:%s", dexName, tempFullPatchedDexFile.getAbsolutePath(), tempFullPatchedDexFile.length(), relatedInfo.newOrFullPatchedMd5);
}

开始针对两个dex文件做dex diff,最终将生成的patch 文件放置在目标文件夹中。

接下来,生成一个临时文件夹,通过DexPatchApplier针对生成的patch文件和old dex file,直接做了合并操作,相当于在本地模拟执行了在客户端上的patch操作。

然后再对新合并生成的patchedNewDex与之前的origNewDex,进行了checkDexChange,即这两者类级别对比,应该所有的类都相同。

最后在dexDecoder的onAllPatchesEnd中还执行了一个addTestDex

private void addTestDex() throws IOException {
    //write test dex
    String dexMode = "jar";
    if (config.mDexRaw) {
        dexMode = "raw";
    }

    final InputStream is = DexDiffDecoder.class.getResourceAsStream("/" + TEST_DEX_NAME);
    String md5 = MD5.getMD5(is, 1024);
    is.close();

    String meta = TEST_DEX_NAME + "," + "" + "," + md5 + "," + md5 + "," + 0 + "," + 0 + "," + dexMode;

    File dest = new File(config.mTempResultDir + "/" + TEST_DEX_NAME);
    FileOperation.copyResourceUsingStream(TEST_DEX_NAME, dest);
    Logger.d("\nAdd test install result dex: %s, size:%d", dest.getAbsolutePath(), dest.length());
    Logger.d("DexDecoder:write test dex meta file data: %s", meta);

    metaWriter.writeLineToInfoFile(meta);
}

copy了一个test.dex文件至目标文件夹,该文件存储在tinker-patch-lib的resources文件夹下,主要用于在app上进行测试。

完成了所有的diff工作后,后面就是生成patch文件了。

(2)打包所有生成的patch文件

//build patch
PatchBuilder builder = new PatchBuilder(config);
builder.buildPatch();

详细代码:

public PatchBuilder(Configuration config) {
    this.config = config;
    this.unSignedApk = new File(config.mOutFolder, PATCH_NAME + "_unsigned.apk");
    this.signedApk = new File(config.mOutFolder, PATCH_NAME + "_signed.apk");
    this.signedWith7ZipApk = new File(config.mOutFolder, PATCH_NAME + "_signed_7zip.apk");
    this.sevenZipOutPutDir = new File(config.mOutFolder, TypedValue.OUT_7ZIP_FILE_PATH);
}

public void buildPatch() throws Exception {
    final File resultDir = config.mTempResultDir;
    //no file change
    if (resultDir.listFiles().length == 0) {
        return;
    }
generateUnsignedApk(unSignedApk);
    signApk(unSignedApk, signedApk);

    use7zApk(signedApk, signedWith7ZipApk, sevenZipOutPutDir);

    if (!signedApk.exists()) {
        Logger.e("Result: final unsigned patch result: %s, size=%d", unSignedApk.getAbsolutePath(), unSignedApk.length());
    } else {
        long length = signedApk.length();
        Logger.e("Result: final signed patch result: %s, size=%d", signedApk.getAbsolutePath(), length);
        if (signedWith7ZipApk.exists()) {
            long length7zip = signedWith7ZipApk.length();
            Logger.e("Result: final signed with 7zip patch result: %s, size=%d", signedWith7ZipApk.getAbsolutePath(), length7zip);
            if (length7zip > length) {
                Logger.e("Warning: %s is bigger than %s %d byte, you should choose %s at these time!",
                    signedWith7ZipApk.getName(),
                    signedApk.getName(),
                    (length7zip - length),
                    signedApk.getName());
            }
        }
    }

}

主要会生成3个文件:unSignedApksignedApk以及signedWith7ZipApk

unSignedApk只要将tinker_result中的文件压缩到一个压缩包即可。
signedApk将unSignedApk使用jarsigner进行签名。

signedWith7ZipApk主要是对signedApk进行解压再做sevenZip压缩。

好了,到此茫茫长的文章就结束啦~~~

受限于本人知识,文中难免出现错误,可以直接留言指出。

九、总结

一直关注tinker的更新,也在项目中对tinker进行了使用与定制,tinker中包含了大量的可学习的知识,项目本身在也具有非常强的价值。

对于tinker的“技术的初心与坚持”一文感触颇深,希望tinker越来越好~

可以阅读以下文章,继续了解tinker~~


支持我的话可以关注下我的公众号,每天都会推送新知识~

欢迎关注我的微信公众号:hongyang钱柜娱乐开户
(可以给我留言你想学习的文章,支持投稿)

作者:lmj623565791 发表于2017/5/23 23:13:53 原文链接
阅读:22023 评论:9 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/72170299 /lmj623565791/article/details/72170299 lmj623565791 2017/5/16 9:23:28

本文已在我的公众号hongyang钱柜娱乐开户原创首发。
转载请标明出处:
/lmj623565791/article/details/72170299
本文出自张鸿洋的博客

一、概述

上周我的微信公众号推送了一篇钱柜娱乐开户 实现”透明屏幕,当时我看到之后就觉得特别感兴趣,也立即联系作者要了授权~~

欢迎大家扫描左侧二维码关注我的公众号,每天7点半推送优秀技术博文。

感兴趣的原因是,我是内涵段子的资深用户,前段时间基本被一款叫火萤视频桌面的软件(就是将视频作为桌面)给刷屏了,所以看了下作者的代码,看到了SurfaceHolder,立刻想到了,肯定可以用来播放视频实现视频桌面的效果,于是周末尝试了下,果然很简单。

所以本篇文章无限感谢钱柜娱乐开户 实现”透明屏幕一文,代码也部分参考自其提供的透明相机。

https://github.com/songixan/Wallpaper

效果图是这样的:

注:本文的测试机为小米5s ,可能不同手机会有一些兼容性问题,尝试解决下,欢迎留言。

二、实现

(1) 配置相关

首先编写一个xml文件,用于描述wallpaper的thumbnaildescriptionsettingsActivity等,这里为了简单,仅设置了thumbnail。

<?xml version="1.0" encoding="utf-8"?>
<wallpaper xmlns:钱柜娱乐开户="http://schemas.钱柜娱乐开户.com/apk/res/钱柜娱乐开户"
    钱柜娱乐开户:thumbnail="@mipmap/ic_launcher" />

(2)编写代码

Wallpaper需要在屏幕上一直显示,其背后其实是一个Service,所以实现一个Wallpaper需要继承自WallpaperService,实现其抽象方法onCreateEngine,如下:

public class VideoLiveWallpaper extends WallpaperService {
    public Engine onCreateEngine() {
        return new VideoEngine();
    }
    //...
}   

可以看到返回值是一个Engine,Engine为WallpaperService的内部类,其内部包含onSurfaceCreatedonSurfaceChangedonSurfaceDestroyedonTouchEvent等方法,看到这些方法,立刻想到了SurfaceView,关于SurfaceView相关知识可以参考:

此外,大家还记得在钱柜娱乐开户播放视频吗?

常规的做法有通过VideoView,除此以外还有通过MediaPlayer配合SurfaceView配合来实现,今天这个例子类似后者。

我们只需要通过MediaPlayer将解码的数据不断的输送到传入的Surface中即可。

class VideoEngine extends Engine {

    private MediaPlayer mMediaPlayer;

    @Override
    public void onSurfaceCreated(SurfaceHolder holder) {
        L.d("VideoEngine#onSurfaceCreated ");
        super.onSurfaceCreated(holder);
        mMediaPlayer = new MediaPlayer();
        mMediaPlayer.setSurface(holder.getSurface());
        try {
            AssetManager assetMg = getApplicationContext().getAssets();
            AssetFileDescriptor fileDescriptor = assetMg.openFd("test1.mp4");
            mMediaPlayer.setDataSource(fileDescriptor.getFileDescriptor(),
                    fileDescriptor.getStartOffset(), fileDescriptor.getLength());
            mMediaPlayer.setLooping(true);
            mMediaPlayer.setVolume(0, 0);
            mMediaPlayer.prepare();
            mMediaPlayer.start();

        } catch (IOException e) {
            e.printStackTrace();
        }

    }

     @Override
    public void onVisibilityChanged(boolean visible) {
        L.d("VideoEngine#onVisibilityChanged visible = " + visible);
        if (visible) {
            mMediaPlayer.start();
        } else {
            mMediaPlayer.pause();
        }
    }

    @Override
    public void onSurfaceDestroyed(SurfaceHolder holder) {
        L.d("VideoEngine#onSurfaceDestroyed ");
        super.onSurfaceDestroyed(holder);
        mMediaPlayer.release();
        mMediaPlayer = null;

    }

代码非常简单,在onSurfaceCreated中去初始化mMediaPlayer,核心代码即为设置setSurface方法,这里我默认设置了静音。

onVisibilityChanged,即当桌面不可见时,我们要暂停播放,等回到桌面继续。

当onSurfaceDestroyed时释放资源~~

这样我们的VideoLiveWallpaper就写好了,别忘了他是个Service,需要我们去注册。

<service
    钱柜娱乐开户:name=".VideoLiveWallpaper"
    钱柜娱乐开户:label="@string/app_name"
    钱柜娱乐开户:permission="钱柜娱乐开户.permission.BIND_WALLPAPER"
    钱柜娱乐开户:process=":wallpaper">
    <!-- 配置intent-filter -->
    <intent-filter>
        <action 钱柜娱乐开户:name="钱柜娱乐开户.service.wallpaper.WallpaperService" />
    </intent-filter>
    <!-- 配置meta-data -->
    <meta-data
        钱柜娱乐开户:name="钱柜娱乐开户.service.wallpaper"
        钱柜娱乐开户:resource="@xml/livewallpaper" />
</service>

(3)设置为壁纸

注册完成后,我们在MainActivity中添加一个按钮点击设置为桌面背景,调用代码如下

public static void setToWallPaper(Context context) {
    final Intent intent = new Intent(WallpaperManager.ACTION_CHANGE_LIVE_WALLPAPER);
    intent.putExtra(WallpaperManager.EXTRA_LIVE_WALLPAPER_COMPONENT,
            new ComponentName(context, VideoLiveWallpaper.class));
    context.startActivity(intent);
}

这样就完成了代码的初步编写啦~~

(4)增加一些参数的支持

刚才我们设置了默认是静音,可能有时候我们会希望能够动态去控制视频桌面的参数,正常应该尝试去使用settingsActivity,不过我觉得其实广播也挺合适的,无非就是Service(可能在独立的进程)和Activity等通信嘛~~

这里我们增加一个复选框,支持设置开启声音or关闭声音。

public static final String VIDEO_PARAMS_CONTROL_ACTION = "com.zhy.livewallpaper";
public static final String KEY_ACTION = "action";
public static final int ACTION_VOICE_SILENCE = 110;
public static final int ACTION_VOICE_NORMAL = 111;

class VideoEngine extends Engine {
    // 省略其他代码
    private BroadcastReceiver mVideoParamsControlReceiver;

    @Override
    public void onCreate(SurfaceHolder surfaceHolder) {
        super.onCreate(surfaceHolder);
        IntentFilter intentFilter = new IntentFilter(VIDEO_PARAMS_CONTROL_ACTION);
        registerReceiver(mVideoParamsControlReceiver = new BroadcastReceiver() {
            @Override
            public void onReceive(Context context, Intent intent) {
                L.d("onReceive");
                int action = intent.getIntExtra(KEY_ACTION, -1);

                switch (action) {
                    case ACTION_VOICE_NORMAL:
                        mMediaPlayer.setVolume(1.0f, 1.0f);
                        break;
                    case ACTION_VOICE_SILENCE:
                        mMediaPlayer.setVolume(0, 0);
                        break;
                }
            }
        }, intentFilter);
    }
    @Override
    public void onDestroy() {
        unregisterReceiver(mVideoParamsControlReceiver);
        super.onDestroy();

    }
}

Engine还有onCreate和onDestroy声明周期方法,可以在onCreate中注册动态广播,当接受到发送的action为ACTION_VOICE_NORMAL则开启声音;接收到发送的ACTION_VOICE_SILENCE则为静音状态。

最后直接在VideoLiveWallpaper中添加两个静态方法用于发送广播即可:

public static void voiceSilence(Context context) {
    Intent intent = new Intent(VideoLiveWallpaper.VIDEO_PARAMS_CONTROL_ACTION);
    intent.putExtra(VideoLiveWallpaper.KEY_ACTION, VideoLiveWallpaper.ACTION_VOICE_SILENCE);
    context.sendBroadcast(intent);
}

public static void voiceNormal(Context context) {
    Intent intent = new Intent(VideoLiveWallpaper.VIDEO_PARAMS_CONTROL_ACTION);
    intent.putExtra(VideoLiveWallpaper.KEY_ACTION, VideoLiveWallpaper.ACTION_VOICE_NORMAL);
    context.sendBroadcast(intent);
}

在Actiivty中:

public class MainActivity extends AppCompatActivity {
    private CheckBox mCbVoice;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        mCbVoice = (CheckBox) findViewById(R.id.id_cb_voice);

        mCbVoice.setOnCheckedChangeListener(
                new CompoundButton.OnCheckedChangeListener() {
                    @Override
                    public void onCheckedChanged(
                            CompoundButton buttonView, boolean isChecked) {
                        if (isChecked) {
                            // 静音
                            VideoLiveWallpaper.voiceSilence(getApplicationContext());
                        } else {
                            VideoLiveWallpaper.voiceNormal(getApplicationContext());
                        }
                    }
                });
    }
}

监听一下CheckBox状态,发送广播即可。

ok,这样一个简单的视频桌面就完成啦~~

源码地址:

直接将这个目录以项目形式导入。


支持我的话可以关注下我的公众号,每天都会推送新知识~

欢迎关注我的微信公众号:hongyang钱柜娱乐开户
(可以给我留言你想学习的文章,支持投稿)

参考

作者:lmj623565791 发表于2017/5/16 9:23:28 原文链接
阅读:26602 评论:67 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/60874334 /lmj623565791/article/details/60874334 lmj623565791 2017/3/9 8:44:39

本文已在我的公众号hongyang钱柜娱乐开户首发。
转载请标明出处:
/lmj623565791/article/details/60874334
本文出自张鸿洋的博客

在上一篇文章中,我们介绍了钱柜娱乐开户 热修复 Tinker接入及源码浅析,里面包含了热修的一些背景知识,从tinker对dex文件的处理来看,源码大体上可以分为3部分阅读:

  1. 在应用中对patch的合并与加载,已经在上篇文章中详细介绍过了钱柜娱乐开户 热修复 Tinker接入及源码浅析
  2. 详细的dex patch,dex diff算法
  3. tinker gradle plugin相关知识

tinker有个非常大的亮点就是自研发了一套dex diff、patch相关算法。本篇文章主要目的就是分析该算法。当然值得注意的是,分析的前提就是需要对dex文件的格式要有一定的认识,否则的话可能会一脸懵逼态。

所以,本文会先对dex文件格式做一个简单的分析,也会做一些简单的实验,最后进入到dex diff,patch算法部分。

一、Dex文件格式浅析

首先简单了解下Dex文件,大家在反编译的时候,都清楚apk中会包含一个或者多个*.dex文件,该文件中存储了我们编写的代码,一般情况下我们还会通过工具转化为jar,然后通过一些工具反编译查看。

jar文件大家应该都清楚,类似于class文件的压缩包,一般情况下,我们直接解压就可以看到一个个class文件。而dex文件我们无法通过解压获取内部的一个个class文件,说明dex文件拥有自己特定的格式:

dex对JAVA类文件重新排列,将所有JAVA类文件中的常量池分解,消除其中的冗余信息,重新组合形成一个常量池,所有的类文件共享同一个常量池,使得相同的字符串、常量在DEX文件中只出现一次,从而减小了文件的体积。引自:/jason0539/article/details/50440669

接下来我们看看dex文件的内部结构到底是什么样子。

分析一个文件的组成,最好自己编写一个最简单的dex文件来分析。

(1)编写代码生成dex

首先我们编写一个类Hello.java

public class Hello{
    public static void main(String[] args){
        System.out.println("hello dex!");
    }
}

然后进行编译:

javac -source 1.7 -target 1.7 Hello.java

最后通过dx工作将其转化为dex文件:

dx --dex --output=Hello.dex Hello.class

dx路径在钱柜娱乐开户-sdk/build-tools/版本号/dx下,如果无法识别dx命令,记得将该路径放到path下,或者使用绝对路径。

这样我们就得到了一个非常简单的dex文件。

(2)查看dex文件的内部结构

首先展示一张dex文件的大致的内部结构图:

该图来自dodola的tinker文章->AloneMonkey 的博客

当然,单纯从一张图来说明肯定是远远不够的,因为我们后续要研究diff,patch算法,理论上我们应该要知道更多的细节,甚至要细致到:一个dex文件的每个字节表示的是什么内容。

对于一个类似于二进制的文件,最好的办法肯定不是靠记忆,好在有这么一个软件可以帮助我们的分析:

下载完成安装后,打开我们的dex文件,会引导你安装dex文件的解析模板。

最终打开效果图如下:

上面部分代表了dex文件的内容(16进制的方式展示),下面部分展示了dex文件的各个区域,你可以通过点击下面部分,来查看其对应的内容区域以及内容。

当然这里也非常建议,阅读一些专门的文章来加深对dex文件的理解:

本文也仅会对dex文件做简单的格式分析。

(3)dex文件内部结构简单分析

dex_header

首先我们队dex_header做一个大致的分析,header中包含如下字段:

首先我们猜测下header的作用,可以看到起包含了一些校验相关的字段,和整个dex文件大致区块的分布(off都为偏移量)。

这样的好处就是,当虚拟机读取dex文件时,只需要读取出header部分,就可以知道dex文件的大致区块分布了;并且可以检验出该文件格式是否正确、文件是否被篡改等。

  • magic能够证明该文件是dex文件
  • checksum和signature主要用于校验文件的完整性
  • file_size为dex文件的大小
  • head_size为头文件的大小
  • endian_tag预设值为12345678,标识默认采用Little-Endian(自行搜索)。

剩下的几乎都是成对出现的size和off,大多代表各区块的包含的特定数据结构的数量和偏移量。例如:string_ids_off为112,指的是偏移量112开始为string_ids区域;string_ids_size为14,代表string_id_item的数量为14个。剩下的都类似就不介绍了。

结合010Editor可以看到各个区域包含的数据结构,以及对应的值,慢慢看就好了。

dex_map_list

除了header还有个比较重要的部分是dex_map_list,首先看个图:

首先是map_item_list数量,接下来是每个map_item_list的描述。

map_item_list有什么用呢?

可以看到每个map_list_item包含一个枚举类型,一个2字节暂未使用的成员、一个size表明当前类型的个数,offset表明当前类型偏移量。

拿本例来说:

  • 首先是TYPE_HEADER_ITEM类型,包含1个header(size=1),且偏移量为0。
  • 接下来是TYPE_STRING_ID_ITEM,包含14个string_id_item(size=14),且偏移量为112(如果有印象,header的长度为112,紧跟着header)。

剩下的依次类推~~

这样的话,可以看出通过map_list,可以将一个完整的dex文件划分成固定的区域(本例为13),且知道每个区域的开始,以及该区域对应的数据格式的个数。

通过map_list找到各个区域的开始,每个区域都会对应特定的数据结构,通过010 Editor看就好了。

二、分析前的思考

现在我们了解了dex的基本格式,接下来我们考虑下如何做dex diff 和 patch。

先要考虑的是我们有什么:

  1. old dex
  2. new dex

我们想要生成一个patch文件,该文件和old dex 通过patch算法还能生成new dex。

  • 那么我们该如何做呢?

根据上文的分析,我们知道dex文件大致有3个部分(这里3个部分主要用于分析,勿较真):

  1. header
  2. 各个区域
  3. map list

header实际上是可以根据后面的数据确定其内容的,并且是定长112的;各个区域后面说;map list实际上可以做到定位到各个区域开始位置;

我们最终patch + old dex -> new dex;针对上述的3个部分,

  • header我们可以不做处理,因为可以根据其他数据生成;
  • map list这个东西,其实我们主要要的是各个区域的开始(offset)
  • 知道了各个区域的offset后,在我们生成new dex的时候,我们就可以定位各个区域的开始和结束位置,那么只需要往各个区域写数据即可。

那么我们看看针对一个区域的diff,假设有个string区域,主要用于存储字符串:

old dex该区域的字符串有: Hello、World、zhy
new dex该区域的字符串有: 钱柜娱乐开户、World、zhy

可以看出,针对该区域,我们删除了Hello,增加了钱柜娱乐开户。

那么patch中针对该区域可以如下记录:
“del Hello , add 钱柜娱乐开户”(实际情况需要转化为二进制)。

想想应用中可以直接读取出old dex,即知道:

  • 原来该区域包含:Hello、World、zhy
  • patch中该区域包含:”del Hello , add 钱柜娱乐开户”

那么,可以非常容易的计算出new dex中包含:

钱柜娱乐开户、World、zhy。

这样我们就完成了一个区域大致的diff和patch算法,其他各个区域的diff和patch和上述类似。

这样来看,是不是觉得这个diff和patch算法也没有那么的复杂,实际上tinker的做法与上述类似,实际情况可能要比上述描述要复杂一些,但是大体上是差不多的。

有了一个大致的算法概念之后,我们就可以去看源码了。

三、Tinker DexDiff源码浅析

tinker的地址:

这里看代码实际上也是有技巧的,tinker的代码实际上蛮多的,往往你可以会陷在一堆的代码中。我们可以这么考虑,比如diff算法,输入参数为old dex 、new dex,输出为patch file。

那么肯定存在某个类,或者某个方法接受和输出上述参数。实际上该类为DexPatchGenerator

diff的API使用代码为:

@Test
public void testDiff() throws IOException {
    File oldFile = new File("Hello.dex");
    File newFile = new File("Hello-World.dex");

    File patchFile = new File("patch.dex");
    DexPatchGenerator dexPatchGenerator
            = new DexPatchGenerator(oldFile, newFile);
    dexPatchGenerator.executeAndSaveTo(patchFile);
}

代码在tinker-build的tinker-patch-lib下。

写一个单元测试或者main方法,上述几行代码就是diff算法。

所以查看代码时要有针对性,比如看diff算法,就找到diff算法的入口,不要在gradle plugin中去纠结。

(1)dex file => Dex

public DexPatchGenerator(File oldDexFile, File newDexFile) throws IOException {
    this(new Dex(oldDexFile), new Dex(newDexFile));
}

将我们传入的dex文件转化为了Dex对象。

public Dex(File file) throws IOException {
    // 删除了一堆代码
    InputStream  in = new BufferedInputStream(new FileInputStream(file));
    loadFrom(in, (int) file.length());     
}


private void loadFrom(InputStream in, int initSize) throws IOException {
    byte[] rawData = FileUtils.readStream(in, initSize);
    this.data = ByteBuffer.wrap(rawData);
    this.data.order(ByteOrder.LITTLE_ENDIAN);
    this.tableOfContents.readFrom(this);
}

首先将我们的文件读取为byte[]数组(这里还是蛮耗费内存的),然后由ByteBuffer进行包装,并设置字节顺序为小端(这里说明ByteBuffer还是蛮方便的。然后通过readFrom方法为Dex对象的tableOfContents赋值。

#TableOfContents
public void readFrom(Dex dex) throws IOException {
    readHeader(dex.openSection(header));
    // special case, since mapList.byteCount is available only after
    // computeSizesFromOffsets() was invoked, so here we can't use
    // dex.openSection(mapList) to get dex section. Or
    // an {@code java.nio.BufferUnderflowException} will be thrown.
    readMap(dex.openSection(mapList.off));
    computeSizesFromOffsets();
}

在其内部执行了readHeader和readMap,上文我们大致分析了header和map list相关,实际上就是将这两个区域转化为一定的数据结构,读取然后存储到内存中。

首先看readHeader:

private void readHeader(Dex.Section headerIn) throws UnsupportedEncodingException {
    byte[] magic = headerIn.readByteArray(8);
    int apiTarget = DexFormat.magicToApi(magic);

    if (apiTarget != DexFormat.API_NO_EXTENDED_OPCODES) {
        throw new DexException("Unexpected magic: " + Arrays.toString(magic));
    }

    checksum = headerIn.readInt();
    signature = headerIn.readByteArray(20);
    fileSize = headerIn.readInt();
    int headerSize = headerIn.readInt();
    if (headerSize != SizeOf.HEADER_ITEM) {
        throw new DexException("Unexpected header: 0x" + Integer.toHexString(headerSize));
    }
    int endianTag = headerIn.readInt();
    if (endianTag != DexFormat.ENDIAN_TAG) {
        throw new DexException("Unexpected endian tag: 0x" + Integer.toHexString(endianTag));
    }
    linkSize = headerIn.readInt();
    linkOff = headerIn.readInt();
    mapList.off = headerIn.readInt();
    if (mapList.off == 0) {
        throw new DexException("Cannot merge dex files that do not contain a map");
    }
    stringIds.size = headerIn.readInt();
    stringIds.off = headerIn.readInt();
    typeIds.size = headerIn.readInt();
    typeIds.off = headerIn.readInt();
    protoIds.size = headerIn.readInt();
    protoIds.off = headerIn.readInt();
    fieldIds.size = headerIn.readInt();
    fieldIds.off = headerIn.readInt();
    methodIds.size = headerIn.readInt();
    methodIds.off = headerIn.readInt();
    classDefs.size = headerIn.readInt();
    classDefs.off = headerIn.readInt();
    dataSize = headerIn.readInt();
    dataOff = headerIn.readInt();
}

如果你现在打开着010 Editor,或者看一眼最前面的图,实际上就是将header中所有的字段定义出来,读取响应的字节并赋值。

接下来看readMap:

private void readMap(Dex.Section in) throws IOException {
    int mapSize = in.readInt();
    Section previous = null;
    for (int i = 0; i < mapSize; i++) {
        short type = in.readShort();
        in.readShort(); // unused
        Section section = getSection(type);
        int size = in.readInt();
        int offset = in.readInt();

        section.size = size;
        section.off = offset;

        previous = section;
    }

    header.off = 0;

    Arrays.sort(sections);

    // Skip header section, since its offset must be zero.
    for (int i = 1; i < sections.length; ++i) {
        if (sections[i].off == Section.UNDEF_OFFSET) {
            sections[i].off = sections[i - 1].off;
        }
    }
}

这里注意,在读取header的时候,实际上已经读取除了map list区域的offset,并存储在mapList.off中。所以map list中实际上是从这个位置开始的。首先读取的就是map_list_item的个数,接下来读取的就是每个map_list_item对应的实际数据。

可以看到依次读取:type,unused,size,offset,如果你还有印象前面我们描述了map_list_item是与此对应的,对应的数据结构为TableContents.Section对象。

computeSizesFromOffsets()主要为section的byteCount(占据了多个字节)参数赋值。

到这里就完成了dex file 到 Dex对象的初始化。

有了两个Dex对象之后,就需要去做diff操作了。

(2)dex diff

继续回到源码:

public DexPatchGenerator(File oldDexFile, InputStream newDexStream) throws IOException {
    this(new Dex(oldDexFile), new Dex(newDexStream));
}

直接到两个Dex对象的构造函数:

public DexPatchGenerator(Dex oldDex, Dex newDex) {
    this.oldDex = oldDex;
    this.newDex = newDex;

    SparseIndexMap oldToNewIndexMap = new SparseIndexMap();
    SparseIndexMap oldToPatchedIndexMap = new SparseIndexMap();
    SparseIndexMap newToPatchedIndexMap = new SparseIndexMap();
    SparseIndexMap selfIndexMapForSkip = new SparseIndexMap();

    additionalRemovingClassPatternSet = new HashSet<>();

    this.stringDataSectionDiffAlg = new StringDataSectionDiffAlgorithm(
            oldDex, newDex,
            oldToNewIndexMap,
            oldToPatchedIndexMap,
            newToPatchedIndexMap,
            selfIndexMapForSkip
    );
    this.typeIdSectionDiffAlg = ...
    this.protoIdSectionDiffAlg = ...
    this.fieldIdSectionDiffAlg = ...
    this.methodIdSectionDiffAlg = ...
    this.classDefSectionDiffAlg = ...
    this.typeListSectionDiffAlg = ...
    this.annotationSetRefListSectionDiffAlg = ... 
    this.annotationSetSectionDiffAlg = ...
    this.classDataSectionDiffAlg = ... 
    this.codeSectionDiffAlg = ...
    this.debugInfoSectionDiffAlg = ...
    this.annotationSectionDiffAlg = ...
    this.encodedArraySectionDiffAlg = ...
    this.annotationsDirectorySectionDiffAlg = ...
}

看到其首先为oldDex,newDex赋值,然后依次初始化了15个算法,每个算法代表每个区域,算法的目的就像我们之前描述的那样,要知道“删除了哪些,新增了哪些”;

我们继续看代码:

dexPatchGenerator.executeAndSaveTo(patchFile);

有了dexPatchGenerator对象后,直接指向了executeAndSaveTo方法。

public void executeAndSaveTo(File file) throws IOException {
    OutputStream os = null;
    try {
        os = new BufferedOutputStream(new FileOutputStream(file));
        executeAndSaveTo(os);
    } finally {
        if (os != null) {
            try {
                os.close();
            } catch (Exception e) {
                // ignored.
            }
        }
    }
}

到executeAndSaveTo方法:

public void executeAndSaveTo(OutputStream out) throws IOException {
    int patchedheaderSize = SizeOf.HEADER_ITEM;
    int patchedStringIdsSize = newDex.getTableOfContents().stringIds.size * SizeOf.STRING_ID_ITEM;
    int patchedTypeIdsSize = newDex.getTableOfContents().typeIds.size * SizeOf.TYPE_ID_ITEM;

    int patchedProtoIdsSize = newDex.getTableOfContents().protoIds.size * SizeOf.PROTO_ID_ITEM;

    int patchedFieldIdsSize = newDex.getTableOfContents().fieldIds.size * SizeOf.MEMBER_ID_ITEM;
    int patchedMethodIdsSize = newDex.getTableOfContents().methodIds.size * SizeOf.MEMBER_ID_ITEM;
    int patchedClassDefsSize = newDex.getTableOfContents().classDefs.size * SizeOf.CLASS_DEF_ITEM;

    int patchedIdSectionSize =
            patchedStringIdsSize
                    + patchedTypeIdsSize
                    + patchedProtoIdsSize
                    + patchedFieldIdsSize
                    + patchedMethodIdsSize
                    + patchedClassDefsSize;

    this.patchedHeaderOffset = 0;

    this.patchedStringIdsOffset = patchedHeaderOffset + patchedheaderSize;

    this.stringDataSectionDiffAlg.execute();
    this.patchedStringDataItemsOffset = patchedheaderSize + patchedIdSectionSize;

    this.stringDataSectionDiffAlg.simulatePatchOperation(this.patchedStringDataItemsOffset);

    // 省略了其余14个算法的一堆代码
    this.patchedDexSize
            = this.patchedMapListOffset
            + patchedMapListSize;
    writeResultToStream(out);
}

因为涉及到15个算法,所以这里的代码非常长,我们这里只拿其中一个算法来说明。

每个算法都会执行executesimulatePatchOperation方法:

首先看execute:

public void execute() {
    this.patchOperationList.clear();

    // 1. 拿到oldDex和newDex的itemList
    this.adjustedOldIndexedItemsWithOrigOrder = collectSectionItems(this.oldDex, true);
    this.oldItemCount = this.adjustedOldIndexedItemsWithOrigOrder.length;

    AbstractMap.SimpleEntry<Integer, T>[] adjustedOldIndexedItems = new AbstractMap.SimpleEntry[this.oldItemCount];
    System.arraycopy(this.adjustedOldIndexedItemsWithOrigOrder, 0, adjustedOldIndexedItems, 0, this.oldItemCount);
    Arrays.sort(adjustedOldIndexedItems, this.comparatorForItemDiff);

    AbstractMap.SimpleEntry<Integer, T>[] adjustedNewIndexedItems = collectSectionItems(this.newDex, false);
    this.newItemCount = adjustedNewIndexedItems.length;
    Arrays.sort(adjustedNewIndexedItems, this.comparatorForItemDiff);

    int oldCursor = 0;
    int newCursor = 0;
    // 2.遍历,对比,收集patch操作
    while (oldCursor < this.oldItemCount || newCursor < this.newItemCount) {
        if (oldCursor >= this.oldItemCount) {
            // rest item are all newItem.
            while (newCursor < this.newItemCount) {
                // 对剩下的newItem做ADD操作
            }
        } else if (newCursor >= newItemCount) {
            // rest item are all oldItem.
            while (oldCursor < oldItemCount) {
                // 对剩下的oldItem做DEL操作
            }
        } else {
            AbstractMap.SimpleEntry<Integer, T> oldIndexedItem = adjustedOldIndexedItems[oldCursor];
            AbstractMap.SimpleEntry<Integer, T> newIndexedItem = adjustedNewIndexedItems[newCursor];
            int cmpRes = oldIndexedItem.getValue().compareTo(newIndexedItem.getValue());
            if (cmpRes < 0) {
                int deletedIndex = oldIndexedItem.getKey();
                int deletedOffset = getItemOffsetOrIndex(deletedIndex, oldIndexedItem.getValue());
                this.patchOperationList.add(new PatchOperation<T>(PatchOperation.OP_DEL, deletedIndex));
                markDeletedIndexOrOffset(this.oldToPatchedIndexMap, deletedIndex, deletedOffset);
                ++oldCursor;
            } else if (cmpRes > 0) {
                this.patchOperationList.add(new PatchOperation<>(PatchOperation.OP_ADD,
                        newIndexedItem.getKey(), newIndexedItem.getValue()));
                ++newCursor;
            } else {
                int oldIndex = oldIndexedItem.getKey();
                int newIndex = newIndexedItem.getKey();
                int oldOffset = getItemOffsetOrIndex(oldIndexedItem.getKey(), oldIndexedItem.getValue());
                int newOffset = getItemOffsetOrIndex(newIndexedItem.getKey(), newIndexedItem.getValue());

                if (oldIndex != newIndex) {
                    this.oldIndexToNewIndexMap.put(oldIndex, newIndex);
                }

                if (oldOffset != newOffset) {
                    this.oldOffsetToNewOffsetMap.put(oldOffset, newOffset);
                }

                ++oldCursor;
                ++newCursor;
            }
        }
    }

    // 未完
}

可以看到首先读取oldDex和newDex对应区域的数据并排序,分别adjustedOldIndexedItems和adjustedNewIndexedItems。

接下来就开始遍历了,直接看else部分:

分别根据当前的cursor,获取oldItem和newItem,对其value对对比:

  • 如果<0 ,则认为该old Item被删除了,记录为PatchOperation.OP_DEL,并记录该oldItem index到PatchOperation对象,加入到patchOperationList中。
  • 如果>0,则认为该newItem是新增的,记录为PatchOperation.OP_ADD,并记录该newItem index和value到PatchOperation对象,加入到patchOperationList中。
  • 如果=0,不会生成PatchOperation。

经过上述,我们得到了一个patchOperationList对象。

继续下半部分代码:

public void execute() {
    // 接上...

    // 根据index排序,如果index一样,则先DEL后ADD
    Collections.sort(this.patchOperationList, comparatorForPatchOperationOpt);

    Iterator<PatchOperation<T>> patchOperationIt = this.patchOperationList.iterator();
    PatchOperation<T> prevPatchOperation = null;
    while (patchOperationIt.hasNext()) {
        PatchOperation<T> patchOperation = patchOperationIt.next();
        if (prevPatchOperation != null
                && prevPatchOperation.op == PatchOperation.OP_DEL
                && patchOperation.op == PatchOperation.OP_ADD
                ) {
            if (prevPatchOperation.index == patchOperation.index) {
                prevPatchOperation.op = PatchOperation.OP_REPLACE;
                prevPatchOperation.newItem = patchOperation.newItem;
                patchOperationIt.remove();
                prevPatchOperation = null;
            } else {
                prevPatchOperation = patchOperation;
            }
        } else {
            prevPatchOperation = patchOperation;
        }
    }

    // Finally we record some information for the final calculations.
    patchOperationIt = this.patchOperationList.iterator();
    while (patchOperationIt.hasNext()) {
        PatchOperation<T> patchOperation = patchOperationIt.next();
        switch (patchOperation.op) {
            case PatchOperation.OP_DEL: {
                indexToDelOperationMap.put(patchOperation.index, patchOperation);
                break;
            }
            case PatchOperation.OP_ADD: {
                indexToAddOperationMap.put(patchOperation.index, patchOperation);
                break;
            }
            case PatchOperation.OP_REPLACE: {
                indexToReplaceOperationMap.put(patchOperation.index, patchOperation);
                break;
            }
        }
    }
}
  1. 首先对patchOperationList按照index排序,如果index一致则先DEL、后ADD。
  2. 接下来一个对所有的operation的迭代,主要将index一致的,且连续的DEL、ADD转化为REPLACE操作。
  3. 最后将patchOperationList转化为3个Map,分别为:indexToDelOperationMapindexToAddOperationMap,indexToReplaceOperationMap

ok,经历完成execute之后,我们主要的产物就是3个Map,分别记录了:oldDex中哪些index需要删除;newDex中新增了哪些item;哪些item需要替换为新item。

刚才说了每个算法除了execute()还有个simulatePatchOperation()

this.stringDataSectionDiffAlg
    .simulatePatchOperation(this.patchedStringDataItemsOffset);

传入的偏移量为data区域的偏移量。

public void simulatePatchOperation(int baseOffset) {
    int oldIndex = 0;
    int patchedIndex = 0;
    int patchedOffset = baseOffset;
    while (oldIndex < this.oldItemCount || patchedIndex < this.newItemCount) {
        if (this.indexToAddOperationMap.containsKey(patchedIndex)) {
            //省略了一些代码
            T newItem = patchOperation.newItem;
            int itemSize = getItemSize(newItem);
            ++patchedIndex;
            patchedOffset += itemSize;
        } else if (this.indexToReplaceOperationMap.containsKey(patchedIndex)) {
            //省略了一些代码
            T newItem = patchOperation.newItem;
            int itemSize = getItemSize(newItem);
            ++patchedIndex;
            patchedOffset += itemSize;
        } else if (this.indexToDelOperationMap.containsKey(oldIndex)) {
            ++oldIndex;
        } else if (this.indexToReplaceOperationMap.containsKey(oldIndex)) {
            ++oldIndex;
        } else if (oldIndex < this.oldItemCount) {
            ++oldIndex;
            ++patchedIndex;
            patchedOffset += itemSize;
        }
    }

    this.patchedSectionSize = SizeOf.roundToTimesOfFour(patchedOffset - baseOffset);
}

遍历oldIndex与newIndex,分别在indexToAddOperationMap,indexToReplaceOperationMap,indexToDelOperationMap中查找。

这里关注一点最终的一个产物是this.patchedSectionSize,由patchedOffset-baseOffset所得。
这里有几种情况会造成patchedOffset+=itemSize

  1. indexToAddOperationMap中包含patchIndex
  2. indexToReplaceOperationMap包含patchIndex
  3. 不在indexToDelOperationMap与indexToReplaceOperationMap中的oldDex.

其实很好理解,这个patchedSectionSize其实对应newDex的这个区域的size。所以,包含需要ADD的Item,会被替代的Item,以及OLD ITEMS中没有被删除和替代的Item。这三者相加即为newDex的itemList。

到这里,一个算法就执行完毕了。

经过这样的一个算法,我们得到了PatchOperationList和对应区域sectionSize。那么执行完成所有的算法,应该会得到针对每个算法的PatchOperationList,和每个区域的sectionSize;每个区域的sectionSize实际上换算得到每个区域的offset。

每个区域的算法,execute和simulatePatchOperation代码都是复用的,所以其他的都只有细微的变化,可以自己看了。

接下来看执行完成所有的算法后的writeResultToStream方法。

(3) 生成patch文件

private void writeResultToStream(OutputStream os) throws IOException {
    DexDataBuffer buffer = new DexDataBuffer();
    buffer.write(DexPatchFile.MAGIC); // DEXDIFF
    buffer.writeShort(DexPatchFile.CURRENT_VERSION); /0x0002
    buffer.writeInt(this.patchedDexSize);
    // we will return here to write firstChunkOffset later.
    int posOfFirstChunkOffsetField = buffer.position();
    buffer.writeInt(0);
    buffer.writeInt(this.patchedStringIdsOffset);
    buffer.writeInt(this.patchedTypeIdsOffset);
    buffer.writeInt(this.patchedProtoIdsOffset);
    buffer.writeInt(this.patchedFieldIdsOffset);
    buffer.writeInt(this.patchedMethodIdsOffset);
    buffer.writeInt(this.patchedClassDefsOffset);
    buffer.writeInt(this.patchedMapListOffset);
    buffer.writeInt(this.patchedTypeListsOffset);
    buffer.writeInt(this.patchedAnnotationSetRefListItemsOffset);
    buffer.writeInt(this.patchedAnnotationSetItemsOffset);
    buffer.writeInt(this.patchedClassDataItemsOffset);
    buffer.writeInt(this.patchedCodeItemsOffset);
    buffer.writeInt(this.patchedStringDataItemsOffset);
    buffer.writeInt(this.patchedDebugInfoItemsOffset);
    buffer.writeInt(this.patchedAnnotationItemsOffset);
    buffer.writeInt(this.patchedEncodedArrayItemsOffset);
    buffer.writeInt(this.patchedAnnotationsDirectoryItemsOffset);
    buffer.write(this.oldDex.computeSignature(false));
    int firstChunkOffset = buffer.position();
    buffer.position(posOfFirstChunkOffsetField);
    buffer.writeInt(firstChunkOffset);
    buffer.position(firstChunkOffset);

    writePatchOperations(buffer, this.stringDataSectionDiffAlg.getPatchOperationList());
    // 省略其他14个writePatch...

    byte[] bufferData = buffer.array();
    os.write(bufferData);
    os.flush();
}
  • 首先写了MAGIC,CURRENT_VERSION主要用于检查该文件为合法的tinker patch 文件。
  • 然后写入patchedDexSize
  • 第四位写入的是数据区的offset,可以看到先使用0站位,等所有的map list相关的offset书写结束,写入当前的位置。
  • 接下来写入所有的跟maplist各个区域相关的offset(这里各个区域的排序不重要,读写一致即可)
  • 然后执行每个算法写入对应区域的信息
  • 最后生成patch文件

我们依旧只看stringDataSectionDiffAlg这个算法。

private <T extends Comparable<T>> void writePatchOperations(
        DexDataBuffer buffer, List<PatchOperation<T>> patchOperationList
) {
    List<Integer> delOpIndexList = new ArrayList<>(patchOperationList.size());
    List<Integer> addOpIndexList = new ArrayList<>(patchOperationList.size());
    List<Integer> replaceOpIndexList = new ArrayList<>(patchOperationList.size());

    List<T> newItemList = new ArrayList<>(patchOperationList.size());

    for (PatchOperation<T> patchOperation : patchOperationList) {
        switch (patchOperation.op) {
            case PatchOperation.OP_DEL: {
                delOpIndexList.add(patchOperation.index);
                break;
            }
            case PatchOperation.OP_ADD: {
                addOpIndexList.add(patchOperation.index);
                newItemList.add(patchOperation.newItem);
                break;
            }
            case PatchOperation.OP_REPLACE: {
                replaceOpIndexList.add(patchOperation.index);
                newItemList.add(patchOperation.newItem);
                break;
            }
        }
    }

    buffer.writeUleb128(delOpIndexList.size());
    int lastIndex = 0;
    for (Integer index : delOpIndexList) {
        buffer.writeSleb128(index - lastIndex);
        lastIndex = index;
    }

    buffer.writeUleb128(addOpIndexList.size());
    lastIndex = 0;
    for (Integer index : addOpIndexList) {
        buffer.writeSleb128(index - lastIndex);
        lastIndex = index;
    }

    buffer.writeUleb128(replaceOpIndexList.size());
    lastIndex = 0;
    for (Integer index : replaceOpIndexList) {
        buffer.writeSleb128(index - lastIndex);
        lastIndex = index;
    }

    for (T newItem : newItemList) {
        if (newItem instanceof StringData) {
            buffer.writeStringData((StringData) newItem);
        } 
        // else 其他类型,write其他类型Data

    }
}

首先将我们的patchOperationList转化为3个OpIndexList,分别对应DEL,ADD,REPLACE,以及将所有的item存入newItemList。

然后依次写入:

  1. del操作的个数,每个del的index
  2. add操作的个数,每个add的index
  3. replace操作的个数,每个需要replace的index
  4. 最后依次写入newItemList.

这里index都做了(这里做了个index - lastIndex操作)

其他的算法也是执行了类似的操作。

最好来看看我们生成的patch是什么样子的:

  1. 首先包含几个字段,证明自己是tinker patch
  2. 包含生成newDex各个区域的offset,即可以将newDex划分了多个区域,定位到起点
  3. 包含newDex各个区域的Item的删除的索引(oldDex),新增的索引和值,替换的索引和值

那么这么看,我们猜测Patch的逻辑时这样的:

  1. 首先根据各个区域的offset,确定各个区域的起点
  2. 读取oldDex各个区域的items,然后根据patch中去除掉oldDex中需要删除的和需要替换的item,再加上新增的item和替换的item即可组成newOld该区域的items。

即,newDex的某个区域的包含:

 oldItems - del - replace + addItems + replaceItems

这么看挺清晰的,下面看代码咯~

四、Tinker DexPatch源码浅析

(1)寻找入口

与diff一样,肯定有那么一个类或者方法,接受old dex File 和 patch File,最后生成new Dex。不要陷在一堆安全校验,apk解压的代码中。

这个类叫做DexPatchApplier,在tinker-commons中。

patch的相关代码如下:

@Test
public void testPatch() throws IOException {
    File oldFile = new File("Hello.dex");
    File patchFile = new File("patch.dex");

    File newFile = new File("new.dex");

    DexPatchApplier dexPatchGenerator
            = new DexPatchApplier(oldFile, patchFile);
    dexPatchGenerator.executeAndSaveTo(newFile);
}

可以看到和diff代码类似,下面看代码去。

(2)源码分析

public DexPatchApplier(File oldDexIn, File patchFileIn) throws IOException {
    this(new Dex(oldDexIn), new DexPatchFile(patchFileIn));
}

oldDex会转化为Dex对象,这个上面分析过,主要就是readHeader和readMap.注意我们的patchFile是转为一个DexPatchFile对象。

public DexPatchFile(File file) throws IOException {
    this.buffer = new DexDataBuffer(ByteBuffer.wrap(FileUtils.readFile(file)));
    init();
}

首先将patch file读取为byte[],然后调用init

private void init() {
    byte[] magic = this.buffer.readByteArray(MAGIC.length);
    if (CompareUtils.uArrCompare(magic, MAGIC) != 0) {
        throw new IllegalStateException("bad dex patch file magic: " + Arrays.toString(magic));
    }

    this.version = this.buffer.readShort();
    if (CompareUtils.uCompare(this.version, CURRENT_VERSION) != 0) {
        throw new IllegalStateException("bad dex patch file version: " + this.version + ", expected: " + CURRENT_VERSION);
    }

    this.patchedDexSize = this.buffer.readInt();
    this.firstChunkOffset = this.buffer.readInt();
    this.patchedStringIdSectionOffset = this.buffer.readInt();
    this.patchedTypeIdSectionOffset = this.buffer.readInt();
    this.patchedProtoIdSectionOffset = this.buffer.readInt();
    this.patchedFieldIdSectionOffset = this.buffer.readInt();
    this.patchedMethodIdSectionOffset = this.buffer.readInt();
    this.patchedClassDefSectionOffset = this.buffer.readInt();
    this.patchedMapListSectionOffset = this.buffer.readInt();
    this.patchedTypeListSectionOffset = this.buffer.readInt();
    this.patchedAnnotationSetRefListSectionOffset = this.buffer.readInt();
    this.patchedAnnotationSetSectionOffset = this.buffer.readInt();
    this.patchedClassDataSectionOffset = this.buffer.readInt();
    this.patchedCodeSectionOffset = this.buffer.readInt();
    this.patchedStringDataSectionOffset = this.buffer.readInt();
    this.patchedDebugInfoSectionOffset = this.buffer.readInt();
    this.patchedAnnotationSectionOffset = this.buffer.readInt();
    this.patchedEncodedArraySectionOffset = this.buffer.readInt();
    this.patchedAnnotationsDirectorySectionOffset = this.buffer.readInt();
    this.oldDexSignature = this.buffer.readByteArray(SizeOf.SIGNATURE);

    this.buffer.position(firstChunkOffset);
}

还记得我们写patch的操作么,先写了MAGIC和Version用于校验该文件是一个patch file;接下来为patchedDexSize和各种offset进行赋值;最后定位到数据区(firstChunkOffset),还记得写的时候,该字段在第四个位置。

定位到该位置后,后面读取的就是数据了,数据存的时候按照如下格式存储的:

  1. del操作的个数,每个del的index
  2. add操作的个数,每个add的index
  3. replace操作的个数,每个需要replace的index
  4. 最后依次写入newItemList.

简单回忆下,我们继续源码分析。

public DexPatchApplier(File oldDexIn, File patchFileIn) throws IOException {
    this(new Dex(oldDexIn), new DexPatchFile(patchFileIn));
}

public DexPatchApplier(
        Dex oldDexIn,
        DexPatchFile patchFileIn) {
    this.oldDex = oldDexIn;
    this.patchFile = patchFileIn;
    this.patchedDex = new Dex(patchFileIn.getPatchedDexSize());
    this.oldToPatchedIndexMap = new SparseIndexMap();
}

除了oldDex,patchFile,还初始化了一个patchedDex作为我们最终输出Dex对象。

构造完成后,直接执行了executeAndSaveTo方法。

public void executeAndSaveTo(File file) throws IOException {
    OutputStream os = null;
    try {
        os = new BufferedOutputStream(new FileOutputStream(file));
        executeAndSaveTo(os);
    } finally {
        if (os != null) {
            try {
                os.close();
            } catch (Exception e) {
                // ignored.
            }
        }
    }
}

直接到executeAndSaveTo(os),该方法代码比较长,我们分3段讲解:

public void executeAndSaveTo(OutputStream out) throws IOException {

    TableOfContents patchedToc = this.patchedDex.getTableOfContents();

    patchedToc.header.off = 0;
    patchedToc.header.size = 1;
    patchedToc.mapList.size = 1;

    patchedToc.stringIds.off
            = this.patchFile.getPatchedStringIdSectionOffset();
    patchedToc.typeIds.off
            = this.patchFile.getPatchedTypeIdSectionOffset();
    patchedToc.typeLists.off
            = this.patchFile.getPatchedTypeListSectionOffset();
    patchedToc.protoIds.off
            = this.patchFile.getPatchedProtoIdSectionOffset();
    patchedToc.fieldIds.off
            = this.patchFile.getPatchedFieldIdSectionOffset();
    patchedToc.methodIds.off
            = this.patchFile.getPatchedMethodIdSectionOffset();
    patchedToc.classDefs.off
            = this.patchFile.getPatchedClassDefSectionOffset();
    patchedToc.mapList.off
            = this.patchFile.getPatchedMapListSectionOffset();
    patchedToc.stringDatas.off
            = this.patchFile.getPatchedStringDataSectionOffset();
    patchedToc.annotations.off
            = this.patchFile.getPatchedAnnotationSectionOffset();
    patchedToc.annotationSets.off
            = this.patchFile.getPatchedAnnotationSetSectionOffset();
    patchedToc.annotationSetRefLists.off
            = this.patchFile.getPatchedAnnotationSetRefListSectionOffset();
    patchedToc.annotationsDirectories.off
            = this.patchFile.getPatchedAnnotationsDirectorySectionOffset();
    patchedToc.encodedArrays.off
            = this.patchFile.getPatchedEncodedArraySectionOffset();
    patchedToc.debugInfos.off
            = this.patchFile.getPatchedDebugInfoSectionOffset();
    patchedToc.codes.off
            = this.patchFile.getPatchedCodeSectionOffset();
    patchedToc.classDatas.off
            = this.patchFile.getPatchedClassDataSectionOffset();
    patchedToc.fileSize
            = this.patchFile.getPatchedDexSize();

    Arrays.sort(patchedToc.sections);

    patchedToc.computeSizesFromOffsets();

    // 未完待续...

}    

这里实际上,就是读取patchFile中记录的值给patchedDex的TableOfContent中各种Section(大致对应map list中各个map_list_item)赋值。

接下来排序呢,设置byteCount等字段信息。

继续:

public void executeAndSaveTo(OutputStream out) throws IOException {

    // 省略第一部分代码

    // Secondly, run patch algorithms according to sections' dependencies.
    this.stringDataSectionPatchAlg = new StringDataSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.typeIdSectionPatchAlg = new TypeIdSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.protoIdSectionPatchAlg = new ProtoIdSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.fieldIdSectionPatchAlg = new FieldIdSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.methodIdSectionPatchAlg = new MethodIdSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.classDefSectionPatchAlg = new ClassDefSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.typeListSectionPatchAlg = new TypeListSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.annotationSetRefListSectionPatchAlg = new AnnotationSetRefListSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.annotationSetSectionPatchAlg = new AnnotationSetSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.classDataSectionPatchAlg = new ClassDataSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.codeSectionPatchAlg = new CodeSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.debugInfoSectionPatchAlg = new DebugInfoItemSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.annotationSectionPatchAlg = new AnnotationSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.encodedArraySectionPatchAlg = new StaticValueSectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );
    this.annotationsDirectorySectionPatchAlg = new AnnotationsDirectorySectionPatchAlgorithm(
            patchFile, oldDex, patchedDex, oldToPatchedIndexMap
    );

    this.stringDataSectionPatchAlg.execute();
    this.typeIdSectionPatchAlg.execute();
    this.typeListSectionPatchAlg.execute();
    this.protoIdSectionPatchAlg.execute();
    this.fieldIdSectionPatchAlg.execute();
    this.methodIdSectionPatchAlg.execute();
    this.annotationSectionPatchAlg.execute();
    this.annotationSetSectionPatchAlg.execute();
    this.annotationSetRefListSectionPatchAlg.execute();
    this.annotationsDirectorySectionPatchAlg.execute();
    this.debugInfoSectionPatchAlg.execute();
    this.codeSectionPatchAlg.execute();
    this.classDataSectionPatchAlg.execute();
    this.encodedArraySectionPatchAlg.execute();
    this.classDefSectionPatchAlg.execute();

    //未完待续...

}

这一部分很明显初始化了一堆算法,然后分别去执行。我们依然是拿stringDataSectionPatchAlg来分析。

public void execute() {
    final int deletedItemCount = patchFile.getBuffer().readUleb128();
    final int[] deletedIndices = readDeltaIndiciesOrOffsets(deletedItemCount);

    final int addedItemCount = patchFile.getBuffer().readUleb128();
    final int[] addedIndices = readDeltaIndiciesOrOffsets(addedItemCount);

    final int replacedItemCount = patchFile.getBuffer().readUleb128();
    final int[] replacedIndices = readDeltaIndiciesOrOffsets(replacedItemCount);

    final TableOfContents.Section tocSec = getTocSection(this.oldDex);
    Dex.Section oldSection = null;

    int oldItemCount = 0;
    if (tocSec.exists()) {
        oldSection = this.oldDex.openSection(tocSec);
        oldItemCount = tocSec.size;
    }

    // Now rest data are added and replaced items arranged in the order of
    // added indices and replaced indices.
    doFullPatch(
            oldSection, oldItemCount, deletedIndices, addedIndices, replacedIndices
    );
}

再贴一下我们写入时的规则:

  1. del操作的个数,每个del的index
  2. add操作的个数,每个add的index
  3. replace操作的个数,每个需要replace的index
  4. 最后依次写入newItemList.

看代码,读取顺序如下:

  1. del的数量,del的所有的index存储在一个int[]中;
  2. add的数量,add的所有的index存储在一个int[]中;
  3. replace的数量,replace的所有的index存储在一个int[]中;

是不是和写入时一致。

继续,接下来获取了oldDex中oldItems和oldItemCount。

那么现在有了:

  1. del count and indices
  2. add count add indices
  3. replace count and indices
  4. oldItems and oldItemCount

拿着我们拥有的,继续执行doFullPatch

private void doFullPatch(
        Dex.Section oldSection,
        int oldItemCount,
        int[] deletedIndices,
        int[] addedIndices,
        int[] replacedIndices) {
    int deletedItemCount = deletedIndices.length;
    int addedItemCount = addedIndices.length;
    int replacedItemCount = replacedIndices.length;
    int newItemCount = oldItemCount + addedItemCount - deletedItemCount;

    int deletedItemCounter = 0;
    int addActionCursor = 0;
    int replaceActionCursor = 0;

    int oldIndex = 0;
    int patchedIndex = 0;

    while (oldIndex < oldItemCount || patchedIndex < newItemCount) {
        if (addActionCursor < addedItemCount && addedIndices[addActionCursor] == patchedIndex) {
            T addedItem = nextItem(patchFile.getBuffer());
            int patchedOffset = writePatchedItem(addedItem);
            ++addActionCursor;
            ++patchedIndex;
        } else
        if (replaceActionCursor < replacedItemCount && replacedIndices[replaceActionCursor] == patchedIndex) {
            T replacedItem = nextItem(patchFile.getBuffer());
            int patchedOffset = writePatchedItem(replacedItem);
            ++replaceActionCursor;
            ++patchedIndex;
        } else
        if (Arrays.binarySearch(deletedIndices, oldIndex) >= 0) {
            T skippedOldItem = nextItem(oldSection); // skip old item.

            ++oldIndex;
            ++deletedItemCounter;
        } else
        if (Arrays.binarySearch(replacedIndices, oldIndex) >= 0) {
            T skippedOldItem = nextItem(oldSection); // skip old item.

            ++oldIndex;
        } else
        if (oldIndex < oldItemCount) {
            T oldItem = adjustItem(this.oldToPatchedIndexMap, nextItem(oldSection));

            int patchedOffset = writePatchedItem(oldItem);

            ++oldIndex;
            ++patchedIndex;
        }
    }
}

先整体上看一下,这里的目的就是往patchedDex的stringData区写数据,写的数据理论上应该是:

  1. 新增的数据
  2. 替代的数据
  3. oldDex中出去新增和被替代的数据

当然他们需要顺序写入。

所以看代码,首先计算出newItemCount=oldItemCount + addCount - delCount,然后开始遍历,遍历条件为0~oldItemCount0~newItemCount

我们期望的是,在patchIndex从0~newItemCount之间都会写入对应的Item。

Item写入通过代码我们可以看到:

  1. 首先判断该patchIndex是否包含在addIndices中,如果包含则写入;
  2. 再者判断是否在repalceIndices中,如果包含则写入;
  3. 然后判断如果发现oldIndex被delete或者replace,直接跳过;
  4. 那么最后一个index指的就是,oldIndex为非delete和replace的,也就是和newDex中items相同的部分。

上述1.2.4三个部分即可组成完整的newDex的该区域。

这样的话就完成了stringData区域的patch算法。

其他剩下的14个算法的execute代码是相同的(父类),执行的操作类似,都会完成各个部分的patch算法。

当所有的区域都完成恢复后,那么剩下的就是header和mapList了,所以回到所有算法执行完成的地方:

 public void executeAndSaveTo(OutputStream out) throws IOException {

    //1.省略了offset的各种赋值
    //2.省略了各个部分的patch算法

    // Thirdly, write header, mapList. Calculate and write patched dex's sign and checksum.
    Dex.Section headerOut = this.patchedDex.openSection(patchedToc.header.off);
    patchedToc.writeHeader(headerOut);

    Dex.Section mapListOut = this.patchedDex.openSection(patchedToc.mapList.off);
    patchedToc.writeMap(mapListOut);

    this.patchedDex.writeHashes();

    // Finally, write patched dex to file.
    this.patchedDex.writeTo(out);
}

定位到header区域,写header相关数据;定位到map list区域,编写map list相关数据。两者都完成的时候,需要编写header中比较特殊的两个字段:签名checkSum,因为这两个字段是依赖map list的,所以必须在编写map list后。

这样就完成了完整的dex的恢复,最后将内存中的所有数据写到文件中。

五、案例简单分析

(1)dex准备

刚才我们有个Hello.dex,我们再编写一个类:

public class World{
    public static void main(String[] args){
        System.out.println("nani World");
    }
}

然后将这个类编译以及打成dx文件。

javac -source 1.7 -target 1.7 World.java
dx --dex --output=World.dex World.class

这样我们就准备好了两个dex,Hello.dex和World.dex.

(2) diff

使用010 Editor分别打开两个dex,我们主要关注string_id_item

两边分别13个字符串,按照我们上面介绍的diff算法,我们可以得到以下操作:

两边的字符串分别开始遍历对比:

  • 如果<0 ,则认为该old Item被删除了,记录为PatchOperation.OP_DEL,并记录该oldItem index到PatchOperation对象,加入到patchOperationList中。
  • 如果>0,则认为该newItem是新增的,记录为PatchOperation.OP_ADD,并记录该newItem index和value到PatchOperation对象,加入到patchOperationList中。
  • 如果=0,不会生成PatchOperation。
del 1
add 1 LWorld; 
del 2
add 8 World.java
del 10
add 11 naniWorld

然后是根据索引排序,没有变化;

接下来遍历所有的操作,将index一致且DEL和ADD相邻的操作替换为replace

replace 1 LWorld
del 2
add 8 World.java
del 10
add 11 naniWorld

最终在write时,会做一次遍历,将操作按DEL,ADD,REPLACE进行分类,并且将出现的item放置到newItemList中。

del ops:
    del 2
    del 10
add ops:
    add 8
    add 11
replace ops:
    replace 1

newItemList变为:

LWorld //replace 1 
World.java //add 8 
naniWorld //add 11 

然后写入,那么写入的顺序应该是:

2 //del size
2 
8 // index - lastIndex
2 // add size
8
3 // index - lastIndex
1 //replace size
1
LWorld
World.java
naniWorld

这里我们直接在DexPatchGenerator的writeResultToStream的相关位置打上日志:

buffer.writeUleb128(delOpIndexList.size());
System.out.println("del size = " + delOpIndexList.size());
int lastIndex = 0;
for (Integer index : delOpIndexList) {
    buffer.writeSleb128(index - lastIndex);
    System.out.println("del index = " + (index - lastIndex));
    lastIndex = index;
}
buffer.writeUleb128(addOpIndexList.size());
System.out.println("add size = " + addOpIndexList.size());
lastIndex = 0;
for (Integer index : addOpIndexList) {
    buffer.writeSleb128(index - lastIndex);
    System.out.println("add index = " + (index - lastIndex));
    lastIndex = index;
}
buffer.writeUleb128(replaceOpIndexList.size());
System.out.println("replace size = " + addOpIndexList.size());
lastIndex = 0;
for (Integer index : replaceOpIndexList) {
    buffer.writeSleb128(index - lastIndex);
    System.out.println("replace index = " + (index - lastIndex));

    lastIndex = index;
}

for (T newItem : newItemList) {
    if (newItem instanceof StringData) {
        buffer.writeStringData((StringData) newItem);
        System.out.println("stringdata  = " + ((StringData) newItem).value);
      }
}

可以看到输出为:

del size = 2
del index = 2
del index = 8
add size = 2
add index = 8
add index = 3
replace size = 2
replace index = 1
stringdata  = LWorld;
stringdata  = World.java
stringdata  = nani World

与我们上述分析结果一致 ~~

那么其他区域可以用类似的方式去验证,patch的话也差不多,就不赘述了。

支持我的话可以关注下我的公众号,每天都会推送新知识~

欢迎关注我的微信公众号:hongyang钱柜娱乐开户
(可以给我留言你想学习的文章,支持投稿)

参考

作者:lmj623565791 发表于2017/3/9 8:44:39 原文链接
阅读:19820 评论:17 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/58626355 /lmj623565791/article/details/58626355 lmj623565791 2017/3/1 8:28:39

本文已在我的公众号hongyang钱柜娱乐开户首发。
转载请标明出处:
/lmj623565791/article/details/58626355
本文出自张鸿洋的博客

一、概述

在做app性能优化的时候,大家都希望能够写出丝滑的UI界面,以前写过一篇博客,主要是基于Google当时发布的性能优化典范,主要提供一些UI优化性能示例:

实际上,由于各种机型的配置不同、代码迭代历史悠久,代码中可能会存在很多在UI线程耗时的操作,所以我们希望有一套简单检测机制,帮助我们定位耗时发生的位置。

本篇博客主要描述如何检测应用在UI线程的卡顿,目前已经有两种比较典型方式来检测了:

  1. 利用UI线程Looper打印的日志
  2. 利用Choreographer

两种方式都有一些开源项目,例如:

其实编写本篇文章,主要是因为发现一个还比较有意思的方案,该方法的灵感来源于一篇给我微信投稿的文章:

该项目主要用于捕获UI线程的crash,当我看完该项目原理的时候,也可以用来作为检测卡段方案,可能还可以做一些别的事情。

所以,本文出现了3种检测UI卡顿的方案,3种方案原理都比较简单,接下来将逐个介绍。

二、利用loop()中打印的日志

(1)原理

大家都知道在钱柜娱乐开户 UI线程中有个Looper,在其loop方法中会不断取出Message,调用其绑定的Handler在UI线程进行执行。

大致代码如下:

public static void loop() {
    final Looper me = myLooper();

    final MessageQueue queue = me.mQueue;
    // ...
    for (;;) {
        Message msg = queue.next(); // might block
        // This must be in a local variable, in case a UI event sets the logger
        Printer logging = me.mLogging;
        if (logging != null) {
            logging.println(">>>>> Dispatching to " + msg.target + " " +
                    msg.callback + ": " + msg.what);
        }
        // focus
        msg.target.dispatchMessage(msg);

        if (logging != null) {
            logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
        }

        // ...
        }
        msg.recycleUnchecked();
    }
}

所以很多时候,我们只要有办法检测:

msg.target.dispatchMessage(msg);

此行代码的执行时间,就能够检测到部分UI线程是否有耗时操作了。可以看到在执行此代码前后,如果设置了logging,会分别打印出>>>>> Dispatching to<<<<< Finished to这样的log。

我们可以通过计算两次log之间的时间差值,大致代码如下:

public class BlockDetectByPrinter {

    public static void start() {

        Looper.getMainLooper().setMessageLogging(new Printer() {

            private static final String START = ">>>>> Dispatching";
            private static final String END = "<<<<< Finished";

            @Override
            public void println(String x) {
                if (x.startsWith(START)) {
                    LogMonitor.getInstance().startMonitor();
                }
                if (x.startsWith(END)) {
                    LogMonitor.getInstance().removeMonitor();
                }
            }
        });

    }
}

假设我们的阈值是1000ms,当我在匹配到>>>>> Dispatching时,我会在1000ms毫秒后执行一个任务(打印出UI线程的堆栈信息,会在非UI线程中进行);正常情况下,肯定是低于1000ms执行完成的,所以当我匹配到<<<<< Finished,会移除该任务。

大概代码如下:

public class LogMonitor {

    private static LogMonitor sInstance = new LogMonitor();
    private HandlerThread mLogThread = new HandlerThread("log");
    private Handler mIoHandler;
    private static final long TIME_BLOCK = 1000L;

    private LogMonitor() {
        mLogThread.start();
        mIoHandler = new Handler(mLogThread.getLooper());
    }

    private static Runnable mLogRunnable = new Runnable() {
        @Override
        public void run() {
            StringBuilder sb = new StringBuilder();
            StackTraceElement[] stackTrace = Looper.getMainLooper().getThread().getStackTrace();
            for (StackTraceElement s : stackTrace) {
                sb.append(s.toString() + "\n");
            }
            Log.e("TAG", sb.toString());
        }
    };

    public static LogMonitor getInstance() {
        return sInstance;
    }

    public boolean isMonitor() {
        return mIoHandler.hasCallbacks(mLogRunnable);
    }

    public void startMonitor() {
        mIoHandler.postDelayed(mLogRunnable, TIME_BLOCK);
    }

    public void removeMonitor() {
        mIoHandler.removeCallbacks(mLogRunnable);
    }

}

我们利用了HandlerThread这个类,同样利用了Looper机制,只不过在非UI线程中,如果执行耗时达到我们设置的阈值,则会执行mLogRunnable,打印出UI线程当前的堆栈信息;如果你阈值时间之内完成,则会remove掉该runnable。

(2)测试

用法很简单,在Application的onCreate中调用:

BlockDetectByPrinter.start();

即可。

然后我们在Activity里面,点击一个按钮,让睡眠2s,测试下:

findViewById(R.id.id_btn02)
    .setOnClickListener(new View.OnClickListener() {
        @Override
        public void onClick(View v) {
            try {
                Thread.sleep(2000);
            } catch (InterruptedException e) {
            }
        }
    });

运行点击时,会打印出log:

02-21 00:26:26.408 2999-3014/com.zhy.testlp E/TAG: 
java.lang.VMThread.sleep(Native Method)
   java.lang.Thread.sleep(Thread.java:1013)
   java.lang.Thread.sleep(Thread.java:995)
   com.zhy.testlp.MainActivity$2.onClick(MainActivity.java:70)
   钱柜娱乐开户.view.View.performClick(View.java:4438)
   钱柜娱乐开户.view.View$PerformClick.run(View.java:18422)
   钱柜娱乐开户.os.Handler.handleCallback(Handler.java:733)
   钱柜娱乐开户.os.Handler.dispatchMessage(Handler.java:95)

会打印出耗时相关代码的信息,然后可以通过该log定位到耗时的地方。

三、 利用Choreographer

钱柜娱乐开户系统每隔16ms发出VSYNC信号,触发对UI进行渲染。SDK中包含了一个相关类,以及相关回调。理论上来说两次回调的时间周期应该在16ms,如果超过了16ms我们则认为发生了卡顿,我们主要就是利用两次回调间的时间周期来判断:

大致代码如下:

public class BlockDetectByChoreographer {
    public static void start() {
        Choreographer.getInstance()
            .postFrameCallback(new Choreographer.FrameCallback() {
                @Override
                public void doFrame(long l) {
                    if (LogMonitor.getInstance().isMonitor()) {
                        LogMonitor.getInstance().removeMonitor();                    
                    } 
                    LogMonitor.getInstance().startMonitor();
                    Choreographer.getInstance().postFrameCallback(this);
                }
        });
    }
}

第一次的时候开始检测,如果大于阈值则输出相关堆栈信息,否则则移除。

使用方式和上述一致。

四、 利用Looper机制

先看一段代码:

new Handler(Looper.getMainLooper())
        .post(new Runnable() {
            @Override
            public void run() {}
       }

该代码在UI线程中的MessageQueue中插入一个Message,最终会在loop()方法中取出并执行。

假设,我在run方法中,拿到MessageQueue,自己执行原本的Looper.loop()方法逻辑,那么后续的UI线程的Message就会将直接让我们处理,这样我们就可以做一些事情:

public class BlockDetectByLooper {
    private static final String FIELD_mQueue = "mQueue";
    private static final String METHOD_next = "next";

    public static void start() {
        new Handler(Looper.getMainLooper()).post(new Runnable() {
            @Override
            public void run() {
                try {
                    Looper mainLooper = Looper.getMainLooper();
                    final Looper me = mainLooper;
                    final MessageQueue queue;
                    Field fieldQueue = me.getClass().getDeclaredField(FIELD_mQueue);
                    fieldQueue.setAccessible(true);
                    queue = (MessageQueue) fieldQueue.get(me);
                    Method methodNext = queue.getClass().getDeclaredMethod(METHOD_next);
                    methodNext.setAccessible(true);
                    Binder.clearCallingIdentity();
                    for (; ; ) {
                        Message msg = (Message) methodNext.invoke(queue);
                        if (msg == null) {
                            return;
                        }
                        LogMonitor.getInstance().startMonitor();
                        msg.getTarget().dispatchMessage(msg);
                        msg.recycle();
                        LogMonitor.getInstance().removeMonitor();
                    }
                } catch (Exception e) {
                    e.printStackTrace();
                }

            }
        });
    }
}

其实很简单,将Looper.loop里面本身的代码直接copy来了这里。当这个消息被处理后,后续的消息都将会在这里进行处理。

中间有变量和方法需要反射来调用,不过不影响查看msg.getTarget().dispatchMessage(msg);执行时间,但是就不要在线上使用这种方式了。

不过该方式和以上两个方案对比,并无优势,不过这个思路挺有意思的。

使用方式和上述一致。

最后,可以考虑将卡顿日志输出到文件,慢慢分析;可以结合上述原理以及自己需求开发做一个合适的方案,也可以参考已有开源方案。

参考

我的微信公众号:hongyang钱柜娱乐开户
(可以给我留言你想学习的文章,支持投稿)

作者:lmj623565791 发表于2017/3/1 8:28:39 原文链接
阅读:27074 评论:17 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/54882693 /lmj623565791/article/details/54882693 lmj623565791 2017/2/6 10:03:22

本文已在我的公众号hongyang钱柜娱乐开户首发。
转载请标明出处:
/lmj623565791/article/details/54882693
本文出自张鸿洋的博客

一、概述

放了一个大长假,happy,先祝大家2017年笑口常开。

假期中一行代码没写,但是想着马上要上班了,赶紧写篇博客回顾下技能,于是便有了本文。

热修复这项技术,基本上已经成为项目比较重要的模块了。主要因为项目在上线之后,都难免会有各种问题,而依靠发版去修复问题,成本太高了。

现在热修复的技术基本上有阿里的AndFix、QZone的方案、美团提出的思想方案以及腾讯的Tinker等。

其中AndFix可能接入是最简单的一个(和Tinker命令行接入方式差不多),不过兼容性还是是有一定的问题的;QZone方案对性能会有一定的影响,且在Art模式下出现内存错乱的问题(其实这个问题我之前并不清楚,主要是tinker在MDCC上指出的);美团提出的思想方案主要是基于Instant Run的原理,目前尚未开源,不过这个方案我还是蛮喜欢的,主要是兼容性好。

这么看来,如果选择开源方案,tinker目前是最佳的选择,tinker的介绍有这么一句:

Tinker已运行在微信的数亿钱柜娱乐开户设备上,那么为什么你不使用Tinker呢?

好了,说了这么多,下面来看看tinker如何接入,以及tinker的大致的原理分析。希望通过本文可以实现帮助大家更好的接入tinker,以及去了解tinker的一个大致的原理。

二、接入Tinker

接入tinker目前给了两种方式,一种是基于命令行的方式,类似于AndFix的接入方式;一种就是gradle的方式。

考虑早期使用Andfix的app应该挺多的,以及很多人对gradle的相关配置还是觉得比较繁琐的,下面对两种方式都介绍下。

(1)命令行接入

接入之前我们先考虑下,接入的话,正常需要的前提(开启混淆的状态)。

  • 对于API

    一般来说,我们接入热修库,会在Application#onCreate中进行一下初始化操作。然后在某个地方去调用类似loadPatch这样的API去加载patch文件。

  • 对于patch的生成

    简单的方式就是通过两个apk做对比然后生成;需要注意的是:两个apk做对比,需要的前提条件,第二次打包混淆所使用的mapping文件应该和线上apk是一致的。

最后就是看看这个项目有没有需要配置混淆;

有了大致的概念,我们就基本了解命令行接入tinker,大致需要哪些步骤了。

依赖引入

dependencies {
    // ...
    //可选,用于生成application类
    provided('com.tencent.tinker:tinker-钱柜娱乐开户-anno:1.7.7')
    //tinker的核心库
    compile('com.tencent.tinker:tinker-钱柜娱乐开户-lib:1.7.7')
}

顺便加一下签名的配置:

钱柜娱乐开户{
  //...
    signingConfigs {
        release {
            try {
                storeFile file("release.keystore")
                storePassword "testres"
                keyAlias "testres"
                keyPassword "testres"
            } catch (ex) {
                throw new InvalidUserDataException(ex.toString())
            }
        }
    }

    buildTypes {
        release {
            minifyEnabled true
            signingConfig signingConfigs.release
            proguardFiles getDefaultProguardFile('proguard-钱柜娱乐开户.txt'), 'proguard-rules.pro'
        }
        debug {
            debuggable true
            minifyEnabled true
            signingConfig signingConfigs.release
            proguardFiles getDefaultProguardFile('proguard-钱柜娱乐开户.txt'), 'proguard-rules.pro'
        }
    }
}

文末会有demo的下载地址,可以直接参考build.gradle文件,不用担心这些签名文件去哪找。

API引入

API主要就是初始化和loadPacth。

正常情况下,我们会考虑在Application的onCreate中去初始化,不过tinker推荐下面的写法:

@DefaultLifeCycle(application = ".SimpleTinkerInApplication",
        flags = ShareConstants.TINKER_ENABLE_ALL,
        loadVerifyFlag = false)
public class SimpleTinkerInApplicationLike extends ApplicationLike {
    public SimpleTinkerInApplicationLike(Application application, int tinkerFlags, boolean tinkerLoadVerifyFlag, long applicationStartElapsedTime, long applicationStartMillisTime, Intent tinkerResultIntent) {
        super(application, tinkerFlags, tinkerLoadVerifyFlag, applicationStartElapsedTime, applicationStartMillisTime, tinkerResultIntent);
    }

    @Override
    public void onBaseContextAttached(Context base) {
        super.onBaseContextAttached(base);
    }

    @Override
    public void onCreate() {
        super.onCreate();
        TinkerInstaller.install(this);
    }
}

ApplicationLike通过名字你可能会猜,并非是Application的子类,而是一个类似Application的类。

tinker建议编写一个ApplicationLike的子类,你可以当成Application去使用,注意顶部的注解:@DefaultLifeCycle,其application属性,会在编译期生成一个SimpleTinkerInApplication类。

所以,虽然我们这么写了,但是实际上Application会在编译期生成,所以钱柜娱乐开户Manifest.xml中是这样的:

 <application
        钱柜娱乐开户:name=".SimpleTinkerInApplication"
        .../>

编写如果报红,可以build下。

这样其实也能猜出来,这个注解背后有个Annotation Processor在做处理,如果你没了解过,可以看下:

通过该文会对一个编译时注解的运行流程和基本API有一定的掌握,文中也会对tinker该部分的源码做解析。

上述,就完成了tinker的初始化,那么调用loadPatch的时机,我们直接在Activity中添加一个Button设置:


public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }


    public void loadPatch(View view) {
        TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(),
                Environment.getExternalStorageDirectory().getAbsolutePath() + "/patch_signed.apk");
    }
}

我们会将patch文件直接push到sdcard根目录;

所以一定要注意:添加SDCard权限,如果你是6.x以上的系统,自己添加上授权代码,或者手动在设置页面打开SDCard读写权限。

<uses-permission 钱柜娱乐开户:name="钱柜娱乐开户.permission.WRITE_EXTERNAL_STORAGE" />

除以以外,有个特殊的地方就是tinker需要在钱柜娱乐开户Manifest.xml中指定TINKER_ID。

<application>
  <meta-data
            钱柜娱乐开户:name="TINKER_ID"
            钱柜娱乐开户:value="tinker_id_6235657" />
    //...
</application>

到此API相关的就结束了,剩下的就是考虑patch如何生成。

patch生成

tinker提供了patch生成的工具,源码见:tinker-patch-cli,打成一个jar就可以使用,并且提供了命令行相关的参数以及文件。

命令行如下:

java -jar tinker-patch-cli-1.7.7.jar -old old.apk -new new.apk -config tinker_config.xml -out output

需要注意的就是tinker_config.xml,里面包含tinker的配置,例如签名文件等。

这里我们直接使用tinker提供的签名文件,所以不需要做修改,不过里面有个Application的item修改为与本例一致:

<loader value="com.zhy.tinkersimplein.SimpleTinkerInApplication"/>

大致的文件结构如下:

可以在tinker-patch-cli中提取,或者直接下载文末的例子。

上述介绍了patch生成的命令,最后需要注意的就是,在第一次打出apk的时候,保留下生成的mapping文件,在/build/outputs/mapping/release/mapping.txt

可以copy到与proguard-rules.pro同目录,同时在第二次打修复包的时候,在proguard-rules.pro中添加上:

-applymapping mapping.txt

保证后续的打包与线上包使用的是同一个mapping文件。

tinker本身的混淆相关配置,可以参考:

如果,你对该部分描述不了解,可以直接查看源码即可。

测试

首先随便生成一个apk(API、混淆相关已经按照上述引入),安装到手机或者模拟器上。

然后,copy出mapping.txt文件,设置applymapping,修改代码,再次打包,生成new.apk。

两次的apk,可以通过命令行指令去生成patch文件。

如果你下载本例,命令需要在[该目录]下执行。

最终会在output文件夹中生成产物:

我们直接将patch_signed.apk push到sdcard,点击loadpatch,一定要观察命令行是否成功。

本例修改了title。

点击loadPatch,观察log,如果成功,应用默认为重启,然后再次启动即可达到修复效果。

到这里命令行的方式就介绍完了,和Andfix的接入的方式基本上是一样的。

值得注意的是:该例仅展示了基本的接入,对于tinker的各种配置信息,还是需要去读tinker的文档(如果你确定要使用)tinker-wiki

(2)gradle接入

gradle接入的方式应该算是主流的方式,所以tinker也直接给出了例子,单独将该tinker-sample-钱柜娱乐开户以project方式引入即可。

引入之后,可以查看其接入API的方式,以及相关配置。

在你每次build时,会在build/bakApk下生成本地打包的apk,R文件,以及mapping文件。

如果你需要生成patch文件,可以通过:

./gradlew tinkerPatchRelease  // 或者 ./gradlew tinkerPatchDebug

生成。

生成目录为:build/outputs/tinkerPatch

需要注意的是,需要在app/build.gradle中设置相比较的apk(即old.apk,本次为new.apk),

ext {
    tinkerEnabled = true
    //old apk file to build patch apk
    tinkerOldApkPath = "${bakPath}/old.apk"
    //proguard mapping file to build patch apk
    tinkerApplyMappingPath = "${bakPath}/old-mapping.txt"
}

提供的例子,基本上展示了tinker的自定义扩展的方式,具体还可以参考:

所以,如果你使用命令行方式接入,也不要忘了学习下其支持哪些扩展。

三、Application是如何编译时生成的

从注释和命名上看:

//可选,用于生成application类
provided('com.tencent.tinker:tinker-钱柜娱乐开户-anno:1.7.7')

明显是该库,其结构如下:

典型的编译时注解的项目,源码见tinker-钱柜娱乐开户-anno

入口为com.tencent.tinker.anno.AnnotationProcessor,可以在该services/javax.annotation.processing.Processor文件中找到处理类全路径。

再次建议,如果你不了解,简单阅读下钱柜娱乐开户 如何编写基于编译时注解的项目该文。

直接看AnnotationProcessor的process方法:

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
    processDefaultLifeCycle(roundEnv.getElementsAnnotatedWith(DefaultLifeCycle.class));
    return true;
}

直接调用了processDefaultLifeCycle:

private void processDefaultLifeCycle(Set<? extends Element> elements) {
        // 被注解DefaultLifeCycle标识的对象
        for (Element e : elements) {
          // 拿到DefaultLifeCycle注解对象
            DefaultLifeCycle ca = e.getAnnotation(DefaultLifeCycle.class);

            String lifeCycleClassName = ((TypeElement) e).getQualifiedName().toString();
            String lifeCyclePackageName = lifeCycleClassName.substring(0, lifeCycleClassName.lastIndexOf('.'));
            lifeCycleClassName = lifeCycleClassName.substring(lifeCycleClassName.lastIndexOf('.') + 1);

            String applicationClassName = ca.application();
            if (applicationClassName.startsWith(".")) {
                applicationClassName = lifeCyclePackageName + applicationClassName;
            }
            String applicationPackageName = applicationClassName.substring(0, applicationClassName.lastIndexOf('.'));
            applicationClassName = applicationClassName.substring(applicationClassName.lastIndexOf('.') + 1);

            String loaderClassName = ca.loaderClass();
            if (loaderClassName.startsWith(".")) {
                loaderClassName = lifeCyclePackageName + loaderClassName;
            }

             // /TinkerAnnoApplication.tmpl
            final InputStream is = AnnotationProcessor.class.getResourceAsStream(APPLICATION_TEMPLATE_PATH);
            final Scanner scanner = new Scanner(is);
            final String template = scanner.useDelimiter("\\A").next();
            final String fileContent = template
                .replaceAll("%PACKAGE%", applicationPackageName)
                .replaceAll("%APPLICATION%", applicationClassName)
                .replaceAll("%APPLICATION_LIFE_CYCLE%", lifeCyclePackageName + "." + lifeCycleClassName)
                .replaceAll("%TINKER_FLAGS%", "" + ca.flags())
                .replaceAll("%TINKER_LOADER_CLASS%", "" + loaderClassName)
                .replaceAll("%TINKER_LOAD_VERIFY_FLAG%", "" + ca.loadVerifyFlag());
                JavaFileObject fileObject = processingEnv.getFiler().createSourceFile(applicationPackageName + "." + applicationClassName);
                processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Creating " + fileObject.toUri());
          Writer writer = fileObject.openWriter();
            PrintWriter pw = new PrintWriter(writer);
            pw.print(fileContent);
            pw.flush();
            writer.close();

        }
    }

代码比较简单,可以分三部分理解:

  • 步骤1:首先找到被DefaultLifeCycle标识的Element(为类对象TypeElement),得到该对象的包名,类名等信息,然后通过该对象,拿到@DefaultLifeCycle对象,获取该注解中声明属性的值。
  • 步骤2:读取一个模板文件,读取为字符串,将各个占位符通过步骤1中的值替代。
  • 步骤3:通过JavaFileObject将替换完成的字符串写文件,其实就是本例中的Application对象。

我们看一眼模板文件:

package %PACKAGE%;

import com.tencent.tinker.loader.app.TinkerApplication;

/**
 *
 * Generated application for tinker life cycle
 *
 */
public class %APPLICATION% extends TinkerApplication {

    public %APPLICATION%() {
        super(%TINKER_FLAGS%, "%APPLICATION_LIFE_CYCLE%", "%TINKER_LOADER_CLASS%", %TINKER_LOAD_VERIFY_FLAG%);
    }

}

对应我们的SimpleTinkerInApplicationLike

@DefaultLifeCycle(application = ".SimpleTinkerInApplication",
        flags = ShareConstants.TINKER_ENABLE_ALL,
        loadVerifyFlag = false)
public class SimpleTinkerInApplicationLike extends ApplicationLike {}

主要就几个占位符:

  • 包名,如果application属性值以点开始,则同包;否则则截取
  • 类名,application属性值中的类名
  • %TINKER_FLAGS%对应flags
  • %APPLICATION_LIFE_CYCLE%,编写的ApplicationLike的全路径
  • “%TINKER_LOADER_CLASS%”,这个值我们没有设置,实际上对应@DefaultLifeCycle的loaderClass属性,默认值为com.tencent.tinker.loader.TinkerLoader
  • %TINKER_LOAD_VERIFY_FLAG%对应loadVerifyFlag

于是最终生成的代码为:

/**
 *
 * Generated application for tinker life cycle
 *
 */
public class SimpleTinkerInApplication extends TinkerApplication {

    public SimpleTinkerInApplication() {
        super(7, "com.zhy.tinkersimplein.SimpleTinkerInApplicationLike", "com.tencent.tinker.loader.TinkerLoader", false);
    }

}

tinker这么做的目的,文档上是这么说的:

为了减少错误的出现,推荐使用Annotation生成Application类。

这样大致了解了Application是如何生成的。

接下来我们大致看一下tinker的原理。

四、原理

来源于:https://github.com/Tencent/tinker

tinker贴了一张大致的原理图。

可以看出:

tinker将old.apk和new.apk做了diff,拿到patch.dex,然后将patch.dex与本机中apk的classes.dex做了合并,生成新的classes.dex,运行时通过反射将合并后的dex文件放置在加载的dexElements数组的前面。

运行时替代的原理,其实和Qzone的方案差不多,都是去反射修改dexElements。

两者的差异是:Qzone是直接将patch.dex插到数组的前面;而tinker是将patch.dex与app中的classes.dex合并后的全量dex插在数组的前面。

tinker这么做的目的还是因为Qzone方案中提到的CLASS_ISPREVERIFIED的解决方案存在问题;而tinker相当于换个思路解决了该问题。

接下来我们就从代码中去验证该原理。

本片文章源码分析的两条线:

  • 应用启动时,从默认目录加载合并后的classes.dex
  • patch下发后,合成classes.dex至目标目录

五、源码分析

(1)加载patch

加载的代码实际上在生成的Application中调用的,其父类为TinkerApplication,在其attachBaseContext中辗转会调用到loadTinker()方法,在该方法内部,反射调用了TinkerLoader的tryLoad方法。

@Override
public Intent tryLoad(TinkerApplication app, int tinkerFlag, boolean tinkerLoadVerifyFlag) {
    Intent resultIntent = new Intent();

    long begin = SystemClock.elapsedRealtime();
    tryLoadPatchFilesInternal(app, tinkerFlag, tinkerLoadVerifyFlag, resultIntent);
    long cost = SystemClock.elapsedRealtime() - begin;
    ShareIntentUtil.setIntentPatchCostTime(resultIntent, cost);
    return resultIntent;
}

tryLoadPatchFilesInternal中会调用到loadTinkerJars方法:

private void tryLoadPatchFilesInternal(TinkerApplication app, int tinkerFlag, boolean tinkerLoadVerifyFlag, Intent resultIntent) {
    // 省略大量安全性校验代码

    if (isEnabledForDex) {
        //tinker/patch.info/patch-641e634c/dex
        boolean dexCheck = TinkerDexLoader.checkComplete(patchVersionDirectory, securityCheck, resultIntent);
        if (!dexCheck) {
            //file not found, do not load patch
            Log.w(TAG, "tryLoadPatchFiles:dex check fail");
            return;
        }
    }

    //now we can load patch jar
    if (isEnabledForDex) {
        boolean loadTinkerJars = TinkerDexLoader.loadTinkerJars(app, tinkerLoadVerifyFlag, patchVersionDirectory, resultIntent, isSystemOTA);
        if (!loadTinkerJars) {
            Log.w(TAG, "tryLoadPatchFiles:onPatchLoadDexesFail");
            return;
        }
    }
}

TinkerDexLoader.checkComplete主要是用于检查下发的meta文件中记录的dex信息(meta文件,可以查看生成patch的产物,在assets/dex-meta.txt),检查meta文件中记录的dex文件信息对应的dex文件是否存在,并把值存在TinkerDexLoader的静态变量dexList中。

TinkerDexLoader.loadTinkerJars传入四个参数,分别为application,tinkerLoadVerifyFlag(注解上声明的值,传入为false),patchVersionDirectory当前version的patch文件夹,intent,当前patch是否仅适用于art。

@TargetApi(Build.VERSION_CODES.ICE_CREAM_SANDWICH)
public static boolean loadTinkerJars(Application application, boolean tinkerLoadVerifyFlag, 
    String directory, Intent intentResult, boolean isSystemOTA) {
        PathClassLoader classLoader = (PathClassLoader) TinkerDexLoader.class.getClassLoader();

        String dexPath = directory + "/" + DEX_PATH + "/";
        File optimizeDir = new File(directory + "/" + DEX_OPTIMIZE_PATH);

        ArrayList<File> legalFiles = new ArrayList<>();

        final boolean isArtPlatForm = ShareTinkerInternals.isVmArt();
        for (ShareDexDiffPatchInfo info : dexList) {
            //for dalvik, ignore art support dex
            if (isJustArtSupportDex(info)) {
                continue;
            }
            String path = dexPath + info.realName;
            File file = new File(path);

            legalFiles.add(file);
        }
        // just for art
        if (isSystemOTA) {
            parallelOTAResult = true;
            parallelOTAThrowable = null;
            Log.w(TAG, "systemOTA, try parallel oat dexes!!!!!");

            TinkerParallelDexOptimizer.optimizeAll(
                legalFiles, optimizeDir,
                new TinkerParallelDexOptimizer.ResultCallback() {
                }
            );

        SystemClassLoaderAdder.installDexes(application, classLoader, optimizeDir, legalFiles);
        return true;
    }

找出仅支持art的dex,且当前patch是否仅适用于art时,并行去loadDex。

关键是最后的installDexes:

@SuppressLint("NewApi")
public static void installDexes(Application application, PathClassLoader loader, File dexOptDir, List<File> files)
    throws Throwable {

    if (!files.isEmpty()) {
        ClassLoader classLoader = loader;
        if (Build.VERSION.SDK_INT >= 24) {
            classLoader = 钱柜娱乐开户NClassLoader.inject(loader, application);
        }
        //because in dalvik, if inner class is not the same classloader with it wrapper class.
        //it won't fail at dex2opt
        if (Build.VERSION.SDK_INT >= 23) {
            V23.install(classLoader, files, dexOptDir);
        } else if (Build.VERSION.SDK_INT >= 19) {
            V19.install(classLoader, files, dexOptDir);
        } else if (Build.VERSION.SDK_INT >= 14) {
            V14.install(classLoader, files, dexOptDir);
        } else {
            V4.install(classLoader, files, dexOptDir);
        }
        //install done
        sPatchDexCount = files.size();
        Log.i(TAG, "after loaded classloader: " + classLoader + ", dex size:" + sPatchDexCount);

        if (!checkDexInstall(classLoader)) {
            //reset patch dex
            SystemClassLoaderAdder.uninstallPatchDex(classLoader);
            throw new TinkerRuntimeException(ShareConstants.CHECK_DEX_INSTALL_FAIL);
        }
    }
}

这里实际上就是根据不同的系统版本,去反射处理dexElements。

我们看一下V19的实现(主要我看了下本机只有个22的源码~):

private static final class V19 {

    private static void install(ClassLoader loader, List<File> additionalClassPathEntries,
                                File optimizedDirectory)
        throws IllegalArgumentException, IllegalAccessException,
        NoSuchFieldException, InvocationTargetException, NoSuchMethodException, IOException {

        Field pathListField = ShareReflectUtil.findField(loader, "pathList");
        Object dexPathList = pathListField.get(loader);
        ArrayList<IOException> suppressedExceptions = new ArrayList<IOException>();
        ShareReflectUtil.expandFieldArray(dexPathList, "dexElements", makeDexElements(dexPathList,
            new ArrayList<File>(additionalClassPathEntries), optimizedDirectory,
            suppressedExceptions));
        if (suppressedExceptions.size() > 0) {
            for (IOException e : suppressedExceptions) {
                Log.w(TAG, "Exception in makeDexElement", e);
                throw e;
            }
        }
    }
}        
  1. 找到PathClassLoader(BaseDexClassLoader)对象中的pathList对象
  2. 根据pathList对象找到其中的makeDexElements方法,传入patch相关的对应的实参,返回Element[]对象
  3. 拿到pathList对象中原本的dexElements方法
  4. 步骤2与步骤3中的Element[]数组进行合并,将patch相关的dex放在数组的前面
  5. 最后将合并后的数组,设置给pathList

这里其实和Qzone的提出的方案基本是一致的。如果你以前未了解过Qzone的方案,可以参考此文:

(2)合成patch

这里的入口为:

 TinkerInstaller.onReceiveUpgradePatch(getApplicationContext(),
                Environment.getExternalStorageDirectory().getAbsolutePath() + "/patch_signed.apk");

上述代码会调用DefaultPatchListener中的onPatchReceived方法:

# DefaultPatchListener
@Override
public int onPatchReceived(String path) {

    int returnCode = patchCheck(path);

    if (returnCode == ShareConstants.ERROR_PATCH_OK) {
        TinkerPatchService.runPatchService(context, path);
    } else {
        Tinker.with(context).getLoadReporter().onLoadPatchListenerReceiveFail(new File(path), returnCode);
    }
    return returnCode;

}

首先对tinker的相关配置(isEnable)以及patch的合法性进行检测,如果合法,则调用TinkerPatchService.runPatchService(context, path);

public static void runPatchService(Context context, String path) {
    try {
        Intent intent = new Intent(context, TinkerPatchService.class);
        intent.putExtra(PATCH_PATH_EXTRA, path);
        intent.putExtra(RESULT_CLASS_EXTRA, resultServiceClass.getName());
        context.startService(intent);
    } catch (Throwable throwable) {
        TinkerLog.e(TAG, "start patch service fail, exception:" + throwable);
    }
}

TinkerPatchService是IntentService的子类,这里通过intent设置了两个参数,一个是patch的路径,一个是resultServiceClass,该值是调用Tinker.install的时候设置的,默认为DefaultTinkerResultService.class。由于是IntentService,直接看onHandleIntent即可,如果你对IntentService陌生,可以查看此文:钱柜娱乐开户 IntentService完全解析 当Service遇到Handler

@Override
protected void onHandleIntent(Intent intent) {
    final Context context = getApplicationContext();
    Tinker tinker = Tinker.with(context);


    String path = getPatchPathExtra(intent);

    File patchFile = new File(path);

    boolean result;

    increasingPriority();
    PatchResult patchResult = new PatchResult();

    result = upgradePatchProcessor.tryPatch(context, path, patchResult);

    patchResult.isSuccess = result;
    patchResult.rawPatchFilePath = path;
    patchResult.costTime = cost;
    patchResult.e = e;

    AbstractResultService.runResultService(context, patchResult, getPatchResultExtra(intent));

}

比较清晰,主要关注upgradePatchProcessor.tryPatch方法,调用的是UpgradePatch.tryPatch。ps:这里有个有意思的地方increasingPriority(),其内部实现为:

private void increasingPriority() {
    TinkerLog.i(TAG, "try to increase patch process priority");
    try {
        Notification notification = new Notification();
        if (Build.VERSION.SDK_INT < 18) {
            startForeground(notificationId, notification);
        } else {
            startForeground(notificationId, notification);
            // start InnerService
            startService(new Intent(this, InnerService.class));
        }
    } catch (Throwable e) {
        TinkerLog.i(TAG, "try to increase patch process priority error:" + e);
    }
}

如果你对“保活”这个话题比较关注,那么对这段代码一定不陌生,主要是利用系统的一个漏洞来启动一个前台Service。如果有兴趣,可以参考此文:关于 钱柜娱乐开户 进程保活,你所需要知道的一切

下面继续回到tryPatch方法:

# UpgradePatch
@Override
public boolean tryPatch(Context context, String tempPatchPath, PatchResult patchResult) {
    Tinker manager = Tinker.with(context);

    final File patchFile = new File(tempPatchPath);

    //it is a new patch, so we should not find a exist
    SharePatchInfo oldInfo = manager.getTinkerLoadResultIfPresent().patchInfo;
    String patchMd5 = SharePatchFileUtil.getMD5(patchFile);

    //use md5 as version
    patchResult.patchVersion = patchMd5;
    SharePatchInfo newInfo;

    //already have patch
    if (oldInfo != null) {
        newInfo = new SharePatchInfo(oldInfo.oldVersion, patchMd5, Build.FINGERPRINT);
    } else {
        newInfo = new SharePatchInfo("", patchMd5, Build.FINGERPRINT);
    }

    //check ok, we can real recover a new patch
    final String patchDirectory = manager.getPatchDirectory().getAbsolutePath();
    final String patchName = SharePatchFileUtil.getPatchVersionDirectory(patchMd5);
    final String patchVersionDirectory = patchDirectory + "/" + patchName;

    //copy file
    File destPatchFile = new File(patchVersionDirectory + "/" + SharePatchFileUtil.getPatchVersionFile(patchMd5));
    // check md5 first
    if (!patchMd5.equals(SharePatchFileUtil.getMD5(destPatchFile))) {
        SharePatchFileUtil.copyFileUsingStream(patchFile, destPatchFile);
    }

    //we use destPatchFile instead of patchFile, because patchFile may be deleted during the patch process
    if (!DexDiffPatchInternal.tryRecoverDexFiles(manager, signatureCheck, context, patchVersionDirectory, 
                destPatchFile)) {
        TinkerLog.e(TAG, "UpgradePatch tryPatch:new patch recover, try patch dex failed");
        return false;
    }

    return true;
}

拷贝patch文件拷贝至私有目录,然后调用DexDiffPatchInternal.tryRecoverDexFiles

protected static boolean tryRecoverDexFiles(Tinker manager, ShareSecurityCheck checker, Context context,
                                                String patchVersionDirectory, File patchFile) {
    String dexMeta = checker.getMetaContentMap().get(DEX_META_FILE);
    boolean result = patchDexExtractViaDexDiff(context, patchVersionDirectory, dexMeta, patchFile);
    return result;
}

直接看patchDexExtractViaDexDiff

private static boolean patchDexExtractViaDexDiff(Context context, String patchVersionDirectory, String meta, final File patchFile) {
    String dir = patchVersionDirectory + "/" + DEX_PATH + "/";

    if (!extractDexDiffInternals(context, dir, meta, patchFile, TYPE_DEX)) {
        TinkerLog.w(TAG, "patch recover, extractDiffInternals fail");
        return false;
    }

    final Tinker manager = Tinker.with(context);

    File dexFiles = new File(dir);
    File[] files = dexFiles.listFiles();

    ...files遍历执行:DexFile.loadDex
     return true;
}

核心代码主要在extractDexDiffInternals中:

private static boolean extractDexDiffInternals(Context context, String dir, String meta, File patchFile, int type) {
    //parse meta
    ArrayList<ShareDexDiffPatchInfo> patchList = new ArrayList<>();
    ShareDexDiffPatchInfo.parseDexDiffPatchInfo(meta, patchList);

    File directory = new File(dir);
    //I think it is better to extract the raw files from apk
    Tinker manager = Tinker.with(context);
    ZipFile apk = null;
    ZipFile patch = null;

    ApplicationInfo applicationInfo = context.getApplicationInfo();

    String apkPath = applicationInfo.sourceDir; //base.apk
    apk = new ZipFile(apkPath);
    patch = new ZipFile(patchFile);

    for (ShareDexDiffPatchInfo info : patchList) {

        final String infoPath = info.path;
        String patchRealPath;
        if (infoPath.equals("")) {
            patchRealPath = info.rawName;
        } else {
            patchRealPath = info.path + "/" + info.rawName;
        }

        File extractedFile = new File(dir + info.realName);

        ZipEntry patchFileEntry = patch.getEntry(patchRealPath);
        ZipEntry rawApkFileEntry = apk.getEntry(patchRealPath);

        patchDexFile(apk, patch, rawApkFileEntry, patchFileEntry, info, extractedFile);
    }

    return true;
}

这里的代码比较关键了,可以看出首先解析了meta里面的信息,meta中包含了patch中每个dex的相关数据。然后通过Application拿到sourceDir,其实就是本机apk的路径以及patch文件;根据mate中的信息开始遍历,其实就是取出对应的dex文件,最后通过patchDexFile对两个dex文件做合并。

private static void patchDexFile(
            ZipFile baseApk, ZipFile patchPkg, ZipEntry oldDexEntry, ZipEntry patchFileEntry,
            ShareDexDiffPatchInfo patchInfo,  File patchedDexFile) throws IOException {
    InputStream oldDexStream = null;
    InputStream patchFileStream = null;

    oldDexStream = new BufferedInputStream(baseApk.getInputStream(oldDexEntry));
    patchFileStream = (patchFileEntry != null ? new BufferedInputStream(patchPkg.getInputStream(patchFileEntry)) : null);

    new DexPatchApplier(oldDexStream, patchFileStream).executeAndSaveTo(patchedDexFile);

}

通过ZipFile拿到其内部文件的InputStream,其实就是读取本地apk对应的dex文件,以及patch中对应dex文件,对二者的通过executeAndSaveTo方法进行合并至patchedDexFile,即patch的目标私有目录。

至于合并算法,这里其实才是tinker比较核心的地方,这个算法跟dex文件格式紧密关联,如果有机会,然后我又能看懂的话,后面会单独写篇博客介绍。此外dodola已经有篇博客进行了介绍:

感兴趣的可以阅读下。

好了,到此我们就大致了解了tinker热修复的原理~~

测试demo地址:

当然这里只分析了代码了热修复,后续考虑分析资源以及So的热修、核心的diff算法、以及gradle插件等相关知识~


最后欢迎关注我的公众号~

我的微信公众号:hongyang钱柜娱乐开户
(可以给我留言你想学习的文章,支持投稿)

作者:lmj623565791 发表于2017/2/6 10:03:22 原文链接
阅读:31953 评论:90 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/53958285 /lmj623565791/article/details/53958285 lmj623565791 2017/1/1 16:52:02 一直没有写过年终总结,今年有必要回顾一下过去,稍微规划一下来年的计划了。
仔细想想今年比较重要的几个事情就是:

  • 我毕业啦
  • 有了一份正式的工作
  • 公众号开始运营了

当然也有一些方面弱化了,比如博客的更新频率下降到月篇,github项目基本没有维护,慕课网的视频更新频率也非常低,现在还差一个视频呢,一直被我拖延着~

因为之前没有过年终总结,所以就以今年为时间节点,看看这几年的所有的事,所获的成就,做的不够好的事,以及未来想要做的事…

小小的成就

Github

先看看这几年自己一些小小的收获吧~

首先就是Github,号称最大的程序员“交友”网站,我是15年的时候才真正意识到有这么个网站,然后注册的账号~~之前我都是靠CSDN来保存代码的,尴尬~

不过还算是获得了小小的成就:

哈哈,那个第三个就是我,很荣幸获得了中国区Java钱柜娱乐开户的第三位(目前被收录的数据中)~~

数据来源

CSDN

估计大家多数都是CSDN认识的我,在CSDN上的这些日子,收货很大,很开心~

访问量突破1千万,并且目前平均日PV已经破2W了,感觉很自豪。

这个图嘛,看左边个人介绍~

慕课网

大致录了30多门课程,平均每门门课程的学生人数应该也有1.5-2W之间吧,不过慕课经常把我的课程放了N久才上线,感觉都过期了~~

没错,那个叫hyman的就是我~

由于CSDN和慕课的竞争关系,所以也基本没有在博客上推广过视频,估计很多人应该不知道hyman这个名字~~

微信公众号

今年的5月3号正式开始运营,目前已推送180篇左右文章,目前大致有3W的关注量,这半年的时间,感谢你们~~

那么对于我来说,Github、CSDN、微信公众号、慕课网可以作为我的关键词了,还记得今年入职的时候,需要写个个人介绍,想破脑壳写了个:

在CSDN上撰写过多篇文章;在Github上开源了数个项目;维护过技术类微信公众号。

下面聊一聊近几年的个人经历~

关于毕业

这属于一件对我个人来说,非常开心的一件事。13年开始在陕师的读研生涯,不过由于自身携带一定的编程技能,所以基本上都在校外兼职,三年下来几乎同学们都不认识,更糟糕的是研三时期在学术上成果甚微,导致写毕业论文的时候,非常痛苦,一度想要放弃毕业~~还好有导师的帮助,硬着头皮完成了最后的论文,顺利毕业。

现在回想起来蛮轻松的,当时作为一个学生,压力还是达到了非常大的程度~~

简单说下对是否需要读研的看法,很多人问过我这个问题,其实很好判断:如果你准备当一名程序员,但是你或者身边的同学都没有进一线互联网公司的准备,甚至没有人想过,那就读研吧。

误打误撞的开始钱柜娱乐开户

因为上述读研差点没毕业,原因主要就是我读研三年期间基本都在兼职上班,初期我是做JavaEE的,也取得了一些小「成就」,在一家小公司兼职,顺利成为“技术负责人”,封装了项目的整体架构,员工业务培训,甚至还算几位同事的技术导师了~

最近有位同事最近还给我留言了:

小灯光环,如果大家喜欢泡CSDN的论坛应该认识他,你们的版主~~

窃喜~在大家心目中对我的技术还是认可的~~

后期接触钱柜娱乐开户,主要是因为导师需要做了一个钱柜娱乐开户项目,我当时是满脸不屑于做钱柜娱乐开户的,不过没办法,花了大概一周到两周时间,从eoe上下载了一个demo,看了下「PullToRefresh」,「Gson」,自己搭了个后台,非常顺利的完成了人生中第一个钱柜娱乐开户项目。

就因为这个曾经一脸嫌弃的项目,可以作为我人生中一个小的转折点。

在写这个项目的时候,因为需要大量的搜索,让我发现了「郭霖」的博客,当时看了一下目录,一脸懵逼,感觉一个都不会,激发了我学习钱柜娱乐开户的欲望。

记得当时将每篇博客保存为pdf,一篇篇的学习,印象最深刻的事情是,我把他的一个瀑布流的例子,改为本地相片数据源,但是上拉到一定数量就会oom,我还非常用心的写了个例子,附带一大段话发给了作者,想让他帮我看看为什么。最后,他没鸟我…

然后就潜入ddup群,每天收集群里大家的问题,尝试寻找答案,并在几个月后,开始自己写博客。

然后至今,其实写博客对个人的成长还是很快的,虽然表面上看起来有点浪费时间,但是实际上你不写,时间还是浪费了(我今年的感悟就是这样的)~~

关于工作

今年7月,顺利加入了百度大家庭。半年来给我的感觉还是非常棒的,工作对我来说,当然希望可以有一定的业务能力的提升,还能有一定的技术提升,很开心的是是,在这里,除了业务以外,可以有时间让你去研究新技术,并可以得到实施。

而且统治我的老大人和同事人贼好,I do like it !

关于微信公众号

微信公众号是今年开始运营的,申请的比较早,一直没有运营过。这里需要重点感谢几个人「老郭」、钱柜娱乐开户程序员的运行者「汤涛」和「七猫」。

我相信很多人都觉得我的公众号,从各个角度看都和老郭的很相似,甚至每天评论都有人“郭神早上好”~~

没错,我是参考老郭的,因为每天都和老郭在一块聊天(网络上),总会影响到我。

「涛哥」是我运营公众号运营初期,给我帮助非常大的一位朋友,因为运营初期完全不会编辑,遇到各种问题,多亏了涛哥的细心帮助,当时就觉得这个人真的非常nice,不过好像因为精力不足,钱柜娱乐开户程序员公众号交给了「CJJ」运营,期待能够越来越好。

「七猫」是世纪天成的一位钱柜娱乐开户程序员,业余爱好画画,也是我的公众号的每篇文章封面图的作者,非常感谢一直默默的付出~

关于明年的计划

明年第一件事,就是把媳妇接到北京,两个人一起奋斗~

  • 工作上,希望技术上以及各种软实力有持续性的增长
  • 公众号会一如既往的为大家服务,大家可以多多投稿,非常乐意能够让大家的好文被更多的人阅读到
  • 博客会一直更新下去,非常感谢CSDN这个平台,虽然有时候我觉得平台细节做的不够好,但是我会一直在这里
  • ​Github也会跟着博客一起作为代码的仓库进行维护
  • 由于精力有限,视频可能会非常少了

2016即将过去,坚持初心,不负梦想
2017,让我们一起并肩战斗~
大家,新年快乐~~

作者:lmj623565791 发表于2017/1/1 16:52:02 原文链接
阅读:25156 评论:84 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/53370414 /lmj623565791/article/details/53370414 lmj623565791 2016/12/5 8:54:30

本文由我的微信公众号:鸿洋(hongyang钱柜娱乐开户)原创首发。

转载请标明出处:
/lmj623565791/article/details/53370414
本文出自:【张鸿洋的博客】

一、概述

最近和朋友聊天,发现一些灰色产业链通过批量反编译市场上的apk,然后进行注入广告,再重新打包上渠道。

我想大家都不希望自己家的产品或者自己的app那么容易被“占据”,但是想要自身能够防御,首先要知道对方的手段。所以本篇博客的目的不是教大家如何破解别人的app,而是让大家提升安全防御意识,对我们的应用做一些必要的防护,让自己的app不会那么容易被“占领”。

因为是初探,也不需要掌握太多的技术,主要是各种工具的使用了~~

二、工具

几个重要的工具,注意使用最新版本。

相信就是为了学习,大家或多或少都使用过上述几个工具了:

  • apktools主要用户反编译和打包;
  • JD-GUI 主要用于对.class文件展示为源码(比如jar文件)
  • dex2jar 主要用于将dex文件转化为jar文件

如果没有的话,自行下载,尽可能的下载最新版本。

题目是注入广告,那么我们选择一类广告注入,大多数app都有闪屏广告,那么我们就模拟:反编译一个apk,加入我们的闪屏广告页,然后重新打包。

三、步骤

首先需要准备一个apk,我们随便写一个简单的demo。

package com.zhy.decompile;

import 钱柜娱乐开户.support.v7.app.AppCompatActivity;
import 钱柜娱乐开户.os.Bundle;

public class MainActivity extends AppCompatActivity {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

app的样子是这样的,凑合截个图,据说没图不利于阅读。

然后点击run,拿debug的apk就可以,当然不嫌麻烦可以自己签名拿个混淆的apk,也可以随便下载一个小众的app。

1.反编译一个app

./apktool d app.apk 

其中res目录为资源目录,smali目录下可以认为是源码目录,不过都是对应的smali文件。

如果你对smali的语法比较清晰,可以直接在代码中添加逻辑。

我们这里就算了,不过我们这里可以打开res目录,找到activity_main的布局文件,然后修改里面的字符串为:This is hacked app!,这里自己玩。

对了,我们要注入闪屏广告。

思考下,闪屏广告我们可以用Activity来呈现,那么我有个思路是这样的步骤:

  1. 编写闪屏广告页的Activity
  2. 修改钱柜娱乐开户Manifest.xml中的入口Activity为我们闪屏页Activity
  3. 闪屏页面中,3s后跳转到原有的入口Activity

那就搞定了。

好像有什么不对的地方,我们这里的源码都是smali格式的,那么闪屏页的Activity我只会java呀,这怎么转化,有什么大力出奇迹的工具么?

恩,还真有。

工具就是钱柜娱乐开户 Studio,开个玩笑,虽然我们不会,但是我们知道smali文件可以反编译生成,那么我们可以查看反编译apk的包名,然后我们新建一个app,在相同的包名下编写一个闪屏页Activity,然后打包成apk。把这个apk再反编译,提取出闪屏页对应的Smali文件,粘贴到被反编译apk的目录不就好了么。

2. 新建项目(为了Smali文件)

内容如下:

package com.zhy.decompile;

public class HackAdActivity extends AppCompatActivity {

    private Handler mHandler = new Handler(Looper.getMainLooper());

    private Runnable mCallback = new Runnable() {
        @Override
        public void run() {
            Intent intent = new Intent();
            intent.setComponent(new ComponentName("com.zhy.decompile",
                    "com.zhy.decompile.MainActivity"));
            startActivity(intent);
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        mHandler.postDelayed(mCallback, 3000);
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        mHandler.removeCallbacks(mCallback);
    }
}

注意包名一定要和原包名一致&先不要使用到布局文件,后面会说~~

然后提取出apk,重新进行上面的操作,取到Smali文件。

注意我们的编写方式包含内部类,两个一起copy到反编译app的目录。

然后打开钱柜娱乐开户Manifest.xml修改入口Activity…

可以看到入口Activity改为我们新建的Activity了,原来的入口Activity切换为普通Activity了。

到这里,我们的文件就修改完毕了。

然后我们重新打包,与其打包之后的apk,还可以安装,安装后启动首先是闪屏广告页,然后才是原来的页面。

那接下来就是打包了~~

3.打包

./apktool b apk1127 -o app1127_new.apk
./apktool b apk1127 -o app1127_new.apk
I: Using Apktool 2.2.0
I: Checking whether sources has changed...
I: Smaling smali folder into classes.dex...
W: Unknown file type, ignoring: apk1127/smali/.DS_Store
W: Unknown file type, ignoring: apk1127/smali/com/.DS_Store
W: Unknown file type, ignoring: apk1127/smali/com/zhy/.DS_Store
I: Checking whether resources has changed...
I: Building resources...
I: Building apk file...
I: Copying unknown files/dir...

ok,打包成功后,可以看到一个新的app1127_new.apk。

这个apk现在是无法安装的,安装后出现下图结果:

主要是因为没有签名。

那么接下来就开始签名吧~

4.签名

签名的话,我们需要一个签名文件,我们一起来新生成下。

keytool -genkey -alias zhy.keystore -keyalg RSA -validity 20000 -keystore zhy.keystore 

然后按照提示往下输入即可。

当然如果你嫌命令太难记,你也可以利用钱柜娱乐开户 Studio进行可视化生成一个:

点击Build:

选择create New,然后在弹出面板填写就行了,你肯定会填。

有了keystore之后呢,我们可以利用新生成的keystore来签名我们刚才hack的apk。

jarsigner -verbose -sigalg SHA1withRSA -digestalg SHA1 
-keystore zhy.keystore 
-storepass 123456 
app1127_new.apk 
zhy.keystore

记得上述代码弄成一行去执行:

上面的options其实并不多,文件路径,密码,别名呀什么的,应该可以看明白,有兴趣可以详细的搜索下相关文件。

签名完成之后一般就可以安装了,不过我们一般还会做一个对齐操作。

5.对齐

zipalign 4 app1127_new.apk app1127_new_align.apk

此刻运行:

原本只有一个页面,可以看到现在被我们注入了一个I am ad的页面。

当然了,如果你是一路模拟过来的,因为前面说了,先不要使用资源,所以你应该能看出页面的跳转,但是并Ad页面并没有布局文件。

下面我们来说使用布局文件。

四、使用布局文件

HackAdActivity中添加一行:

  setContentView(R.layout.ad);

还是刚才的活,重新反编译copy Smali文件,并且把ad这个layout复制到想要注入的app的反编译后的文件夹中。

然后是不是打包就好了呢?

当然不是,如果是,刚才就直接说好了。我们在写代码的时候,都知道会生成一个R.layout.ad,那么这个值,在原本的app里面肯定是没有的(不考虑重名情况)。
所以,我们需要手动加入进去:

打开R$layout.smali文件:

我们在最后添加一个ad的资源id:

.field public static final ad:I = 0x7f04002e

然后保存退出。

别急着打包…

这里定义完了,我们的HackAdActivity.smali中还需要修改呢。

你别说smali文件里面我看不懂怎么改?

改个id还是可以的。

找到setContentView前一行,是不是还蛮容易定位的。

改完之后,重新打包、签名、对齐就ok了~~

如果你使用了更多的资源,记得基本都要处理。

五、总结

那么到这里就完成了反编译一个apk,然后往里面注入一个新的Activity并且可以自定义这个布局文件,至于这个Activity能看什么事大家肯定都明白。

但是,但是,我们的目的并不是让大家去反编译人家的apk,而是知道我们的apk能够被别人这么玩。

所以要思考的是:

如何预防这种行为呢?
欢迎留言说说如何预防?

未完待续…


欢迎关注我的微博:
http://weibo.com/u/3165018720


微信公众号:hongyang钱柜娱乐开户
(欢迎关注,不要错过每一篇干货,支持投稿)

作者:lmj623565791 发表于2016/12/5 8:54:30 原文链接
阅读:63436 评论:70 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/53240600 /lmj623565791/article/details/53240600 lmj623565791 2016/11/21 8:46:52

本文在我的微信公众号:鸿洋(hongyang钱柜娱乐开户)首发。

转载请标明出处:
/lmj623565791/article/details/53240600
本文出自:【张鸿洋的博客】

一、概述

最近项目准备尝试使用webp来缩小包的体积,于是抽空对相关知识进行了调研和学习。

至于什么是webp,使用webp有什么好处我就不赘述了,具体可以参考腾讯isux上的这篇文章WebP 探寻之路,大致了解下就ok了。

入手大致需要考虑以下几个问题:

  • 如何将现有的jpeg/png等图转化为webp?
  • webp格式的图片如何使用?
  • 有没有兼容性的问题?

下面就跟着上面3个问题开始进行。

二、jpeg/png到webp的互转

这个官方提供了相互转化的工具,以及具体的使用方式,可以参考:

截个图,可以看到左侧的功能列表,包含一系列的功能,encode、decode、view等…

因为有比较详细的文档,这里简单介绍下:

首先下载工具:

我这里下载的是对应mac os的libwebp-0.4.1-mac-10.8-2.tar.gz

下载完成后解压,然后进入bin目录:

MacBook-Pro:bin zhanghongyang01$ pwd
/Users/zhanghongyang01/hongyang/works/libwebp-0.4.1-mac-10.8 2/bin
MacBook-Pro:bin zhanghongyang01$ ls -l
total 5152
-rwxr-xr-x@ 1 zhanghongyang01  staff  1302772  9 20  2014 cwebp
-rwxr-xr-x@ 1 zhanghongyang01  staff   421508  9 20  2014 dwebp
-rwxr-xr-x@ 1 zhanghongyang01  staff   402128  9 20  2014 gif2webp
-rwxr-xr-x@ 1 zhanghongyang01  staff   264588  9 20  2014 vwebp
-rwxr-xr-x@ 1 zhanghongyang01  staff   237376  9 20  2014 webpmux

大致有4个命令工具,分别用于png等转换为webp;webp转化为png;git转化为webp;查看webp图片;最后一个是用于创建webp动画文件的。

(1) jpeg、png 转为webp [cwebp]

cwebp weixin.png -o weixin.webp

(2) webp转为jpeg、png [dwebp]

dwebp weixin.webp -o weixin.png

(3) gif 转化为webp

./gif2webp xingye.gif -o xingye.webp

每个命令都有一堆options,可以自己研究下

三、使用

Webp在app中一般可以用于两个方面

  • 一个是对与服务端交互过程中使用webp图片
  • 另一个是应用中的资源文件

(1)与服务端交互使用webp图片

这种方式非常简单,因为从钱柜娱乐开户4.0开始已经对webp图片进行的支持。

下面我们写个例子,这里我准备了一个webp的图片,我直接放到assets目录,然后编写如下代码:

# 这是一个完全不透明图的测试
Bitmap bitmap = BitmapFactory.decodeStream(getAssets().open("icon.webp"));
imageView.setImageBitmap(bitmap);

找了台4.0.4(API15)的三星手机(ps:实在是找不到4.0的手机了),运行感觉还不错哟~

正在窃喜的时候,我又换了张图片,因为有些时候我们的图部分区域是透明了,于是我找了张图片,转化为webp,按照上述的代码,同样的操作,运行完成后,发现,整个图都显示不出来了

赶紧找了个4.2.2(API17)的手机,显示正常。

于是看一眼文档:

文档上对webp decode和encode的支持,是这样写的:

decode / encode
(钱柜娱乐开户 4.0+)
(Lossless, Transparency, 钱柜娱乐开户 4.2.1+)

https://developer.钱柜娱乐开户.com/guide/appendix/media-formats.html

那么结合文档和实验,大致可以有如下的结论:

  • 4.2.1+ 对于webp的decode、encode是完全支持的(包含半透明的webp图)
  • 对于4.0+ 到 4.2.1 ,只支持完全不透明的decode、encode的webp图
  • 4.0 以下,应该是默认不支持webp了

看到这个结论,那么就是大家的产品最低的支持版本了。

4.2.1起步的话,目前来看,我是不能接受的,所以只有引入so来做低版本兼容了。

(2)兼容so的获取

好在官方已经提供了相关webp支持的源码了,点击下载:

如果你的ndk的知识足够的话,可以自己利用源码,去生成so文件使用。

当然了,你也可以使用前人已经封装好的库:

我们这里选择使用第二个库,这里选择copy它生成的so文件以及辅助类到项目中,你也可以根据其readme打包一个aar出来使用。

首先下载下来webp-钱柜娱乐开户,然后切换到webp-钱柜娱乐开户/src/main/jni,执行ndk-build

然后等待执行结束,可以在其/webp-钱柜娱乐开户/src/main/libs目录下copy出你需要的so,如果需要其他cpu架构的so,可以自己修改Application.mk文件。

/webp-钱柜娱乐开户/src/main/libs
.
├── armeabi
│   └── libwebp_evme.so
├── armeabi-v7a
│   └── libwebp_evme.so
└── x86
    └── libwebp_evme.so

然后将其WebDecoder的辅助类copy到项目中即可,注意保持原有包名。

ok,然后就可以用它提供的decode的方法了:

WebPDecoder.getInstance().decodeWebP(byte[] encoded)

于是,上述以InputStream为webp图片源的代码可以改写为:

# 大致的示例代码
InputStream is = getAssets().open("weixin.webp");
Bitmap bitmap = null;
if (Build.VERSION.SDK_INT > Build.VERSION_CODES.JELLY_BEAN) {
    bitmap = WebPDecoder.getInstance().decodeWebP(streamToBytes(is));
} else {
    bitmap = BitmapFactory.decodeStream(is);
}
imageView.setImageBitmap(bitmap);


private static byte[] streamToBytes(InputStream is) {
    ByteArrayOutputStream os = new ByteArrayOutputStream(1024);
    byte[] buffer = new byte[1024];
    int len;
    try {
        while ((len = is.read(buffer)) >= 0) {
            os.write(buffer, 0, len);
        }
    } catch (java.io.IOException e) {
    }
    return os.toByteArray();
}

ok,这样就可以对4.2.1以下的webp图片进行decode了。

服务端下发的图片为webp格式,然后app去decode显示即可。

注:webp-钱柜娱乐开户这个库只提供了decode方法,如果需要encode需要自己去添加;建议有时间,看下源码中提供的方法,自己利用源码结合ndk相关知识自己做so文件的生成.

(3)应用中的资源文件

除了上述去加载外部图片的方式以外,还有个使用场景就是将项目中的资源文件直接替换为webp。

简单的使用:

直接将png转化为webp,放到res/drawable目录,我们看看效果

这样就可以了~~

从目前来看有2个选择:

  1. 仅替换不存在局部透明的图片,如果项目最小版本是4.0,可以不引入so直接使用。
  2. 全部替换(需要引入so的支持)

第一种,目前来看没什么好介绍的,换图即可。

主要看第二种的处理了,webp-钱柜娱乐开户提供了一种做法是这样的:

<me.everything.webp.WebPImageView
  钱柜娱乐开户:layout_width="wrap_content"
  钱柜娱乐开户:layout_height="wrap_content"
  webp:webp_src="@drawable/your_webp_image" />

这样就可以happy的使用webp了。

但是我一点都不happy,使用webp很多都是已经存在的项目,让我去使用自定义类还要加属性,多麻烦,万一发现坑,我还得一个一个换回去,坚决不干。

所以我们需要一种,可以无缝切换的方式,基本不费力也能还原。

最无缝的方式,就是不动原本的布局文件了,那么如何去动态修改ImageView使其支持Webp呢(4.-)?

其实我们的SDK也有类似的做法,比如对很多View支持了tint属性,原本是不支持的,忽然就支持了,怎么做到的呢?

就是在根据布局文件中ImageView标签名称,创建的时候去做了一些手脚,如果你一脸懵逼,可以先看钱柜娱乐开户 探究 LayoutInflater setFactory

实际上就是利用LayoutInflaterFactory了,有了方案,那么代码就好写了:

public class MainActivity extends AppCompatActivity {

    private static final int[] LL = new int[]
            { //
                    钱柜娱乐开户.R.attr.src,//
            };

    @Override
    protected void onCreate(Bundle savedInstanceState) {

        if(Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN){
            LayoutInflaterCompat.setFactory(LayoutInflater.from(this), new LayoutInflaterFactory() {
                @Override
                public View onCreateView(View parent, String name, Context context, AttributeSet attrs) {

                    AppCompatDelegate delegate = getDelegate();
                    View view = delegate.createView(parent, name, context, attrs);

                    if (view instanceof ImageView) {
                        ImageView imageView = (ImageView) view;
                        TypedArray a = context.obtainStyledAttributes(attrs, LL);
                        int webpSourceResourceID = a.getResourceId(0, 0);
                        if (webpSourceResourceID == 0) { 
                            return view;
                          }
                        InputStream rawImageStream = getResources().openRawResource(webpSourceResourceID);
                        byte[] data = streamToBytes(rawImageStream);
                        final Bitmap webpBitmap = WebPDecoder.getInstance().decodeWebP(data);
                        imageView.setImageBitmap(webpBitmap);
                        a.recycle();
                    }
                    return view;
                }
            });
        }
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
    }
}

一般我们的项目中的Activity都存在一个基类,那么直接在其中添加上述代码即可。

大致逻辑为:对于4.2以下的版本,我们设置一个LayoutInflaterFactory,当创建ImageView的时候,因为AppCompatActivity,ImageView的创建是由上述代码中的delegate指向的对象完成的,我们通过传入attrs,取出用户声明的src属性,经过一系列操作转化为bitmap,最好设置到创建好的ImageView上。

这样,剩下的我们直接将图换成webp就好了,如果发现不适合,只需要去掉这个factory设置的代码即可。

正在我窃喜的时候,忽然发现了一个问题。

就是假设我的资源文件更换并不彻底,还存在部分png的图,但是png的图在4.2以下的版本是不需要上述操作的。

  • 那么问题来了,如何区分webp和非webp的图片资源呢?

当然是根据后缀,那么我们现在能获取的仅仅是图片的resId,还能拿到文件完整的名称吗?

让人开心的是,可以拿到的。

TypedValue value = new TypedValue();

getResources().getValue(webpSourceResourceID, value, true);
String resname = value.string.toString().substring(13, 
        value.string.toString().length());
if (resname.endsWith(".webp")) {
    // do
}

当然应该也可以通过图片的header信息来判断,header判断这种方式应该会更加精确,具体可以查找下相关代码。

对了,如果你的基类是FragmentActivity,那就不需要去设置什么LayoutFactory了,直接复写其onCreateView方法:

onCreateView(View parent, String name, Context context, AttributeSet attrs) {
    final View view = super.onCreateView(parent, name, context, attrs);

    if(view == null){
        if (name.equals("ImageView")) {
            view = new ImageView(context,attrs);
        }
    }

    if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN) {


        if (view instanceof ImageView) {
            ImageView imageView = (ImageView) view;
            TypedArray a = context.obtainStyledAttributes(attrs, LL);
            int webpSourceResourceID = a.getResourceId(0, 0);
              if (webpSourceResourceID == 0) { 
                return view;
            }
            TypedValue value = new TypedValue();

            getResources().getValue(webpSourceResourceID, value, true);
            String resname = value.string.toString().substring(13,
                    value.string.toString().length());
            if (resname.endsWith(".webp")) {
                InputStream rawImageStream = getResources().openRawResource(webpSourceResourceID);
                byte[] data = streamToBytes(rawImageStream);
                Bitmap webpBitmap = WebPDecoder.getInstance().decodeWebP(data);
imageView.setImageBitmap(webpBitmap);

            }
            a.recycle();

        }
    }

    return view;
}

ok,到此应该对于webp都有了一定的认识,也应该大致了解了在钱柜娱乐开户使用webp的兼容性的问题,以及如何处理。

文章中还有很多细节的地方没有去处理,后面要踩得坑还有很多,后续还会有一篇博客来写踩到的坑。

如果你也想用webp,欢迎踩坑与交流。


欢迎关注我的微博:
http://weibo.com/u/3165018720


群号: 497438697 ,欢迎入群

微信公众号:hongyang钱柜娱乐开户
(欢迎关注,不要错过每一篇干货,支持投稿)

参考

作者:lmj623565791 发表于2016/11/21 8:46:52 原文链接
阅读:28763 评论:37 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/52905934 /lmj623565791/article/details/52905934 lmj623565791 2016/10/24 13:33:42 昨晚还在看比赛(war3),小源跑过来问我明天1024,不写篇文章么,想想也是,1024这也算个热点,赶紧来蹭蹭,哈,开个玩笑。

上次谈了谈自己写博客的经历,那么这次就从自身出发来想想该如何学习,首先表明下:

本人刚入行的一名钱柜娱乐开户研发,以下如何学习,描述的都是本人自身的方法,不代表适合所有人,仅为建议参考。

以前在上学期间,有大把的时间学习和游戏,自从加入工作以后,发现时间的分配越来越重要。在面试的时候,询问一些项目中使用的第三方库和一些比较热点的问题的时候,经常听到的答案就是没了解过,业务太忙了,根本没时间。

恩,其实也是,业务的确忙,不干活哪来的薪资。所以上班了之后,如何分配时间就是很关键的问题了。

上班以后给我的一个最大的感受就是:没有非常多的整块时间了,每天剩的就是晚上到家后的3个小时左右,这3个小时可能还不能完全投入到学习中。

所以一定要合理的利用闲碎时间。

准备一个TODO应用

因为没有非常大的整块时间,就不要让学什么这种问题来浪费你的时间。因为我每次在想学什么的时候,都会习惯性打开QQ,然后可能就被吸引过去打dota了(~~(>_<)~~)。

所以,准备一个TODO应用,把学什么这个问题抛给日常生活中。

  • 比如坐地铁的时候,看微信推送的文章,遇到自己没了解过的,把关键词记下来。
  • 在工作中,偶尔搜索问题的时候,发现自己一些某些未了解过的知识的时候,先记下来关键字;工作完成后,凭着关键字再回来学习。
  • 看书过程中,可能也会遇到一些点,书上写的不是非常的清晰,但是的确自己不了解,记下关键字。
  • 和同行吹牛的过程中,别人提到的不会的东西,记下关键字。
  • 在QQ群吹牛的时候,虽然群很水,但是捕获到一些不了解的关键词还是可以的。

千万不要相信自己的记忆力,好记忆不如烂笔头真句话是对的。

所以,准备一个TODO应用或者好用的便签,只要能方便的记录关键字就好。当你有时间的时候,看看自己的记录表,选一个关键字,利用2-3个小时,消化这个关键字。

我就自己写了个demo用来记录:

过了一段时间,可以看看自己曾经遇到了多少个不会的知识点,当下又消化了多少个。

以前我喜欢保存书签,后来发现,其实有关键字就够了,相信程序员是能够用好搜索引擎的。

养成记笔记的习惯

记笔记,这是个非常棒的习惯。

首先,你应该有个笔记本;当然也可以选择电子的,不过我喜欢纸质的。

  • 工作上,对一些问题,进行调研、分析、最终解决方案,这些东西一定要记得总结、整理,记到笔记本上。不然下次遇到这个问题,还要去找代码,找到了还要去想,当时为啥要要这么写那就尴尬了。
  • 看书,对于一本书,你拿到手,基本上不可能里面所有的东西你都不会,也不能所有的东西你都会。所以,在看书的时候,旁边放个笔记本,把看到的写的不错的地方(或者是以前未关注的),记到笔记本上(后续可以根据笔记做验证)。

    不过我一般会把一些未了解过的直接在目录上写下一些关键字,然后可能会将这些标记的部分再读一遍(可能是几遍),最后记录到笔记本上,这样你就能将一本书,浓缩为几页的笔记了,会大大节省你以后复习的时间。

  • 看视频,我现在看视频比较少,不过我大学的时候看过无数的视频,看视频最后的产物最好的就是笔记,代码可能时间长了都会丢失或者忘记。抱着一点印象,去视频中找某个知识点,还是非常痛苦的,再说视频那么占空间,不如删了换点新货。所以,将无数个视频浓缩了一个笔记本,还是非常棒的。

    现在好的视频非常多,也不需要我推荐了,大家都懂。

  • 看博客,恩,同上,记录下你觉得值得记录的东西。

养成良好的阅读源码的习惯

源码阅读,恩,尤其是针对你正在使用的第三方库。

千万不要面试的时候,什么源码都未学习过,理由就是业务太忙,更有甚者说“我觉得没用”。

阅读源码,我一般分为两种,一种为粗读;

大概就是,根据使用的入口,大体的查看类间关系,调用的流程,了解其内部的原理。比如retrofit2,大致粗读,了解核心是动态代理,内部其实依赖okhttp3,接口方法中的注解的方式,实际上是利用反射提取构建okhttp的Request用的。

还有一种是细读;

细读就是看的非常的细致,思考它为什么这么做,甚至遇到对某个地方比较好的处理,拿笔记本完整的将代码记录下来也可以。

粗读了解大致原理,细读吸收其精华。

当然了,说起来容易,实践起来还是挺难的,所以加油吧。

注意阅读源码的前提是你对其是用来干嘛的,以及基本的使用你都了解了。不要随便抓个库,上来就读源码,何必呢~

长期的技术学习规划

上面几点就是在积累比较分散的知识点。

这一点主要是一个大方向的学习计划。

  • 定个期限,读完一本书。不管什么时候,都可以考虑保持长期的读书计划。好处就不多说了,不要在乎一本书的钱,能学到一点东西都是值得的。
  • 长期的学习规划,遇到一些平时用不到,但是想学习的但是又不是几天可以学完的,可以列为长期的学习计划,比如framework,一门脚本语言,React Native等,可以找几个朋友一起学,相互间的督促可能更容易坚持些。我就找过妹子一起学习framework,每周一个方面…

好了,以上就是我的学习方法~

量变引起质变,不坚持,再好的学习方法也没用。


欢迎关注我的微博:
http://weibo.com/u/3165018720


微信公众号:hongyang钱柜娱乐开户
(欢迎关注,不要错过每一篇干货,支持投稿)

作者:lmj623565791 发表于2016/10/24 13:33:42 原文链接
阅读:31940 评论:101 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/52761658 /lmj623565791/article/details/52761658 lmj623565791 2016/10/11 8:45:09

本文在我的微信公众号:鸿洋(hongyang钱柜娱乐开户)首发。

转载请标明出处:
/lmj623565791/article/details/52761658
本文出自:【张鸿洋的博客】

一、概述

最近一直关注热修复的东西,偶尔聊天谈到了增量更新,当然了两个完全不是一个东西。借此找了一些资料,收集整理了一下,本来是不想写博客的,因为主要都是工具的实现,但是昨晚在整理资料的时候,忽然发现,我快要忘了这玩意,又要从头找一圈工具。

So,权当一个记录,也方便以后自己查找。

首先要明确的是,什么是增量更新:

相信大家都见过在应用市场省流量更新软件,一个几百M的软件可能只需要下载一个20M的增量包就能完成更新。那么它是如何做的呢?

就是本篇博客的主题了。

增量更新的流程是:用户手机上安装着某个应用,下载了增量包,手机上的apk和增量包合并形成新的包,然后再次安装(注意这个过程是要重新安装的,当然部分应用市场有root权限你可能感知不到)。

ok,那么把整个流程细化为几个关键点:

  1. 用户手机上提取当前安装应用的apk
  2. 如何利用old.apk和new.apk生成增量文件
  3. 增加文件与1.中的old.apk合并,然后安装

解决了上述3个问题,就ok了。

下面开始解决,首先我们看下增量文件的生成与合并,这个环节可以说是整个流程的核心,也是技术难点,值得开心的是,这个技术难点已经有工具替我们实现了。

二、增量文件的生成与合并

这个其实就是利用工具做二进制的一个diff和patch了。

网址:

下载地址:

对了,本文环境为mac,其他系统如果阻碍,慢慢搜索解决即可。

下载好了,解压,切到对应的目录,然后执行make:

aaa:bsdiff-4.3 zhy$ make
Makefile:13: *** missing separator.  Stop.

恩,你没看错,报错了,这个错误还比较好解决。

解压文件里面有个文件:Makefile,以文本的形式打开,将install:下面的if,endif添加一个缩进。

修改完成是这个样子的:

CFLAGS      +=  -O3 -lbz2

PREFIX      ?=  /usr/local
INSTALL_PROGRAM ?=  ${INSTALL} -c -s -m 555
INSTALL_MAN ?=  ${INSTALL} -c -m 444

all:        bsdiff bspatch
bsdiff:     bsdiff.c
bspatch:    bspatch.c

install:
    ${INSTALL_PROGRAM} bsdiff bspatch ${PREFIX}/bin
    .ifndef WITHOUT_MAN
    ${INSTALL_MAN} bsdiff.1 bspatch.1 ${PREFIX}/man/man1
    .endif

然后,重新执行make:

aaa:bsdiff-4.3 zhy$ make
cc -O3 -lbz2    bsdiff.c   -o bsdiff
cc -O3 -lbz2    bspatch.c   -o bspatch
bspatch.c:39:21: error: unknown type name 'u_char'; did you mean 'char'?
static off_t offtin(u_char *buf)
                    ^~~~~~
                    char

这次比上次好点,这次生成了一个bsdiff,不过在生成bspatch的时候报错了,好在其实我们只需要使用bsdiff,为什么这么说呢?

因为生成增量文件肯定是在服务端,或者是我们本地pc上做的,使用的就是bsdiff这个工具;

另外一个bspatch,合并old.apk和增量文件肯定是在我们应用内部做的。

当然这个问题也是可以解决的,搜索下,很多解决方案,我们这里就不继续在这个上面浪费篇幅了。

我这里提供个下载地址:

https://github.com/hyman钱柜娱乐开户/tools/tree/master/bsdiff-4.3

下载完成,直接make,bsdiff和bspatch都会生成(mac环境下)。

=============神奇的分割线==============

ok,假设到这里,不管你使用何种手段,咱们已经有了bsdiff和bspacth,下面演示下这个工具的使用:

首先我们准备两个apk,old.apk和new.apk,你可以自己随便写个项目,先运行一次拿到生成的apk作为old.apk;然后修改些代码,或者加一些功能,再运行一次生成new.apk;

  • 生成增量文件
./bsdiff old.apk new.apk old-to-new.patch

这样就生成了一个增量文件old-to-new.patch

  • 增量文件和old.apk合并成新的apk
./bspatch old.apk new2.apk old-to-new.patch

这样就生成一个new2.apk

那么怎么证明这个生成的new2.apk和我们的new.apk一模一样呢?

我们可以查看下md5的值,如果两个文件md5值一致,那么几乎可以肯定两个文件时一模一样的(不要跟我较真说什么碰撞可以产生一样的md5的值~~)。

aaa:bsdiff-4.3 zhy$ md5 new.apk 
MD5 (new.apk) = 0900d0d65f49a0cc3b472e14da11bde7
aaa:bsdiff-4.3 zhy$ md5 new2.apk 
MD5 (new2.apk) = 0900d0d65f49a0cc3b472e14da11bde7

可以看到两个文件的md5果然一样~~

恩,假设你不是mac,怎么获取一个文件的md5呢?(自己写代码,下载工具,不要遇到这样的问题,还弹窗我,我会被扣工资的…)

那么到这里我们就已经知道了如何生成增量文件和将patch与旧的文件合并为新的文件。那么我们再次梳理下整个流程:

  1. 服务端已经做好了增量文件(本节完成)
  2. 客户端下载增量文件+提取该应用的apk,使用bspatch合并
  3. 产生的新的apk,调用安装程序

还是蛮清晰的,那么主要是第二点,第二点有两件事,一个是提取应用的apk;一个是使用bspatch合并,那么这个合并肯定是需要native方法和so文件去做的,也就是说我们要自己打个so出来;

三、客户端的行为

(1)提取应用的apk文件

其实提取当前应用的apk非常简单,如下代码:

public class ApkExtract {
    public static String extract(Context context) {
        context = context.getApplicationContext();
        ApplicationInfo applicationInfo = context.getApplicationInfo();
        String apkPath = applicationInfo.sourceDir;
        Log.d("hongyang", apkPath);
        return apkPath;
    }
}

(2)制作bspatch so

首先声明一个类,写个native方法,如下:

public class BsPatch {

    static {
        System.loadLibrary("bsdiff");
    }

    public static native int bspatch(String oldApk, String newApk, String patch);

}

三个参数已经很明确了;

同时别忘了在module的build.gradle下面:

defaultConfig {
    ndk {
        moduleName = 'bsdiff'
    }
}

注意该步骤需要你配置过ndk的环境(下载ndk,设置ndk.dir)~

ok,接下来就是去完成c的代码的编写了;

首先在app/main目录下新建一个文件夹jni,把之前下载的bsdiff中的bspatch.c拷贝进去;

然后按照jni的规则,在里面新建一个方法:

JNIEXPORT jint JNICALL Java_com_zhy_utils_BsPatch_bspatch
        (JNIEnv *env, jclass cls,
         jstring old, jstring new, jstring patch){
    int argc = 4;
    char * argv[argc];
    argv[0] = "bspatch";
    argv[1] = (char*) ((*env)->GetStringUTFChars(env, old, 0));
    argv[2] = (char*) ((*env)->GetStringUTFChars(env, new, 0));
    argv[3] = (char*) ((*env)->GetStringUTFChars(env, patch, 0));


    int ret = patchMethod(argc, argv);

    (*env)->ReleaseStringUTFChars(env, old, argv[1]);
    (*env)->ReleaseStringUTFChars(env, new, argv[2]);
    (*env)->ReleaseStringUTFChars(env, patch, argv[3]);
    return ret;
}

方法名是有规律的,这个规律不用提了吧~~

注意bsdiff.c中并没有patchMethod方法,这个方法实际上是main方法,直接修改为patchMethod即可,觉得复杂没关系,文末有源码。

ok,此时你可以尝试运行,会提示依赖bzlib,其实从文件顶部的include中也能看出来。

既然依赖,那我们就导入吧:

首先下载:

下载完成后,解压:

将其中的.h和.c文件提取出来,然后可以选择连文件夹copy到我们module的app/main/jni下,结果如下:

记得修改bsdiff中的include:

#include "bzip2/bzlib.h"

再次运行;

然后会发现报一堆类似下面的错误:

Error:(70) multiple definition of `main'

提示main方法重复定义了,在出错信息中会给出哪些类中包含main方法,可以选择直接将这些类中的main方法直接删除。

删除以后,就ok了~~

那么到这里,我们就完成了JNI的编写,当然文件是bsdiff提供的c源码。

四、增量更新后安装

上面的操作完成后,最后一步就简单了,首先准备两个apk:

old.apk new.apk

然后制作一个patch,下面代码中的PATCH.patch;

将old.apk安装,然后将new.apk以及PATCH.patch放置到存储卡;

最后在Activity中触发调用:

private void doBspatch() {
    final File destApk = new File(Environment.getExternalStorageDirectory(), "dest.apk");
    final File patch = new File(Environment.getExternalStorageDirectory(), "PATCH.patch");

    //一定要检查文件都存在

    BsPatch.bspatch(ApkExtract.extract(this),
            destApk.getAbsolutePath(),
            patch.getAbsolutePath());

    if (destApk.exists())
        ApkExtract.install(this, destApk.getAbsolutePath());
    }

记得开启读写SDCard权限,记得在代码中校验需要的文件都存在。

install实际就是通过Intent去安装了:

 public static void install(Context context, String apkPath) {
        Intent i = new Intent(Intent.ACTION_VIEW);
        i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
        i.setDataAndType(Uri.fromFile(new File(apkPath)),
                "application/vnd.钱柜娱乐开户.package-archive");
        context.startActivity(i);
    }

这里7.0可能会有问题,把路径暴露给别的app了,应该需要FileProvider去实现(未实验,猜测可能有可能)。

大致的效果图如下:

五、总结

如果你只是单纯的要使用该功能,大可以直接将生成的so文件拷入,直接loadLibrary使用即可。

其次,在做增量更新的时候,patch肯定是根据你当前的版本号与最新(或者目标)版本apk,比对下发diff文件,于此同时应该也把目标apk的md5下发,再做完合并后,不要忘记校验下md5;

博客结束,虽然很简单,主要利用工具实现,但是还是建议自己去实现一次,想一次性跑通还是需要一些时间的,可能过程中也会发现一些坑,也能提升自己对JNI的熟练度。

源码:

也可以选择直接使用so


欢迎关注我的微博:
http://weibo.com/u/3165018720


群号: 497438697 ,欢迎入群

微信公众号:hongyang钱柜娱乐开户
(欢迎关注,不要错过每一篇干货,支持投稿)

参考以及相关链接

作者:lmj623565791 发表于2016/10/11 8:45:09 原文链接
阅读:42747 评论:72 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/52506545 /lmj623565791/article/details/52506545 lmj623565791 2016/9/12 8:59:45

本文已授权微信公众号:鸿洋(hongyang钱柜娱乐开户)在微信公众号平台原创首发。

转载请标明出处:
/lmj623565791/article/details/52506545
本文出自:【张鸿洋的博客】

一、概述

大家编写项目的时候,肯定会或多或少的使用Log,尤其是发现bug的时候,会连续在多个类中打印Log信息,当问题解决了,然后又像狗一样一行一行的去删除刚才随便添加的Log,有时候还要几个轮回才能删除干净。

当然了,我们有很多方案可以不去删除:

  • 我们可以通过gradle去配置debug、release常量去区分
  • 可以对Log进行一层封装,通过debug开关常量来控制

当然了,更多时候我们是不得不删除的,比如修bug着急的时候,一些Log.e("TAG","马丹,到底是不是null,obj = "+=obj),各种词汇符号应该都会有。

所以,我们的需求是这样的:

  1. 可以对Log封装,通过debug开关来控制正常日志信息的输出
  2. 在修bug时,用于定位的杂乱log日志,我们希望可以在bug解除后,很快的定位到,然后删除灭迹。

ok,我们今天要谈的就是Log的封装,当然封装不仅仅是是上述的好处,我们还可以让使用更加便捷,打出来的Log信息展示的更加优雅。

比如:

这个库,就对Log的信息的展示做了非常多的处理,展示给大家是一个非常nice的效果:

当然今天的博文不是去介绍该库,或者是源码解析,不过解析的文章我最后收到了投稿,可以关注我的公众号,近期应该会推送。

今天文章的目标是:掌握这类库的核心原理,以后只要遇到该类库,大家都能说出其本质,以及可以自己去封装一个适合自己的日志库。

二、可行性

对于好用,我觉得如下用法就可以:

L.e("heiheihei");

对于好定位,当然是可以通过日志信息点击,定位到具体行,所以今天demo代码的效果是这样的:

当然了,你可以根据自己喜好,去添加各种信息,以及装饰。

那么,现在最大的一个问题就是

  • 我怎么输出具体的日志调用行呢?

这个秘密就在:

Thread.currentThread().getStackTrace();

我们可以通过当前的线程,拿到当前调用的栈帧集合(称呼不一定准备)。

  • 这个栈帧集合是什么玩意呢?

你可以理解为当我们调用方法的时候,每进入一个方法,会将该方法的相关信息(例如:类名,方法名,方法调用行数等)存储下来,压入到一个栈中,当方法返回的时候再将其出栈。

下面看个具体的例子:

@Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);
        a();
    }

    void a() {
        b();
    }

    void b() {
        StringBuffer err = new StringBuffer();
        StackTraceElement[] stack = Thread.currentThread().getStackTrace();
        for (int i = 0; i < stack.length; i++) {
            err.append("\tat ");
            err.append(stack[i].toString());
            err.append("\n");
        }
        Log.e("TAG", err.toString());
    }

我在onCreate中,调用了a方法,然后a中调用的b方法。在b方法中打印出当前线程中的栈帧集合信息。

at dalvik.system.VMStack.getThreadStackTrace(Native Method)
at java.lang.Thread.getStackTrace(Thread.java:579)
at com.zxy.recovery.test.MainActivity.b(MainActivity.java:26)
at com.zxy.recovery.test.MainActivity.a(MainActivity.java:21)
at com.zxy.recovery.test.MainActivity.onCreate(MainActivity.java:17)
at 钱柜娱乐开户.app.Activity.performCreate(Activity.java:5231)
...

可以看到我们整个方法的调用过程,底部的最先开始调用,顺序为onCreate->a->b->Thread.getStackTrace->VMStack.getThreadStackTrace.

最后两个是因为我们的stacks是在VMStack.getThreadStackTrace方法中获取,然后返回的,所以包含了这两个的内部调用信息。

这里我们直接调用的StackTraceElement的toString方法,它内部有:

  • getClassName
  • getMethodName
  • getFileName
  • getLineNumber

看名字就知道什么意思了,我们可以根据这些信息拼接要打印的信息。

所以,不管怎么说,我们现在已经确定了,可以通过该种方式得到我们的调用某个方法的行数,而且是支持点击跳转到指定位置的。

到这里相当于,方案的可行性就通过了,剩下就是码代码了。

三、实现

先写个大致的代码:

public class L{
    private static boolean sDebug = true;
    private static String sTag = "zhy";

    public static void init(boolean debug, String tag){
        L.sDebug = debug;
        L.sTag = tag;
    }

    public static void e(String msg, Object... params){
        e(null, msg, params);
    }

    public static void e(String tag, String msg, Object[] params){
        if (!sDebug) return;
        tag = getFinalTag(tag);
        //TODO 通过stackElement打印具体log执行的行数
        Log.e(tag, content);
    }

    private static String getFinalTag(String tag){
        if (!TextUtils.isEmpty(tag)){
            return tag;
        }
        return sTag;
    }
}

因为我平时基本上只用Log.e,所以我就不对其他方法进行处理了,你可以根据你的喜好来决定。

ok,那么现在只有一个地方没有处理,就是打印log执行的类以及代码行。

我在onCreate的17行调用了:

L.e("Hello World");

然后在e()方法中,打印了所有的栈帧信息:

E/zhy:    at dalvik.system.VMStack.getThreadStackTrace(Native Method)
          at java.lang.Thread.getStackTrace(Thread.java:579)
          at com.zxy.recovery.test.L.e(L.java:32)
          at com.zxy.recovery.test.L.e(L.java:25)
          at com.zxy.recovery.test.MainActivity.onCreate(MainActivity.java:19)
          at 钱柜娱乐开户.app.Activity.performCreate(Activity.java:5231)
          //...
E/zhy: Hello World

我们要输出的就是上述的MainActivity.onCreate(MainActivity.java:19)

  • 那么我们如何定位呢?

观察上面的信息,因为我们的入口是L类的方法,所以,我们直接遍历,L类相关的下一个非L类的栈帧信息就是具体调用的方法。

于是我们这么写:

private StackTraceElement getTargetStackTraceElement() {
    // find the target invoked method
    StackTraceElement targetStackTrace = null;
    boolean shouldTrace = false;
    StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
    for (StackTraceElement stackTraceElement : stackTrace) {
        boolean isLogMethod = stackTraceElement.getClassName().equals(L.class.getName());
        if (shouldTrace && !isLogMethod) {
            targetStackTrace = stackTraceElement;
            break;
        }
        shouldTrace = isLogMethod;
    }
    return targetStackTrace;
}

拿到确定的方法调用相关的栈帧之后,就是输出啦~~

添加到e()方法中:

public static void e(String tag, String msg, Object... params) {
    if (!sDebug) return;

    String finalTag = getFinalTag(tag);
    StackTraceElement targetStackTraceElement = getTargetStackTraceElement();
    Log.e(finalTag, "(" + targetStackTraceElement.getFileName() + ":"
            + targetStackTraceElement.getLineNumber() + ")");
    Log.e(finalTag, String.format(msg, params));
}

现在再看下输出结果:

现在就可以迅速的定位到日志输出行,再也不要全局搜索去查找了~

到这里,对于我个人的需求已经满足了,如果你有特殊需要,比如也想像logger那样搞个框,那就自己绘制吧,也可以参考它的源码。

对了,还有json,有时候希望可以看json字符串更加的直观,像looger那样:

你可以参考它的做法,其实就是将json字符串,通过JsonArray和JsonObject进行了一个类似format这样的操作。

 private static String getPrettyJson(String jsonStr) {
    try {
        jsonStr = jsonStr.trim();
        if (jsonStr.startsWith("{")) {
            JSONObject jsonObject = new JSONObject(jsonStr);
            return jsonObject.toString(JSON_INDENT);
        }
        if (jsonStr.startsWith("[")) {
            JSONArray jsonArray = new JSONArray(jsonStr);
            return jsonArray.toString(JSON_INDENT);
        }
    } catch (JSONException e) {
        e.printStackTrace();
    }
    return "Invalid Json, Please Check: " + jsonStr;
}

重点就是文本的处理了,其他的和普通log一致。

你可以独立一个L.json()方法。

L.json("{\"name\":\"张鸿洋\",\"age\":24}");

效果如下:

好了,我自己在每次输出前后加了个横线,根据自己的喜欢定制吧。

四、其他用法

StackElementStack在其他一些SDK里面也会用到,比如处理app的crash,有时候会重新处理下信息。

还有就是一些统计PV相关的SDK,会强制要求在某些方法中执行某个方法,例如,必须在Activity.onResume中执行,PVSdk.onResume,如果你之前遇到过某个SDK给你抛了类似的异常,那么它的原理就是这么实现的。

大致的代码如下,可能会有漏洞,随手写的:

public class PVSdk {

    public static void onResume() {
        StackTraceElement[] stackTrace = Thread.currentThread().getStackTrace();
        boolean result = false;
        for (StackTraceElement stackTraceElement : stackTrace) {
            String methodName = stackTraceElement.getMethodName();
            String className = stackTraceElement.getClassName();
            try {
                boolean assignableFromClass = Class.forName(className).isAssignableFrom(Activity.class);
                if (assignableFromClass && "onResume".equals(methodName)) {
                    result = true;
                    break;
                }
            } catch (ClassNotFoundException e) {
                // ignored
            }
        }
        if (!result)
            throw new RuntimeException("PVSdk.onResume must in Activity.onResume");
        //do other things
    }
}

大多时候上述代码实在debug时候开启的,发版状态可能会关闭检查,具体看自己的需求了。

包括自己再写一些库的时候,强绑定生命周期也能这么去简单的check.

五、总结

那么到此文章就结束了,虽然文章比较容易,不过我觉得也能解决一类问题,希望看了这个文章以后,对于任何的日志库脑子里对其实现的原理都非常清晰,看到其本质,很多时候就觉得这个东西很简单了。

最后,文章中的代码,和源码略有不同,因为源码可能会是封装后的,文章中代码是为了便于描述,都是越直观越好。

源码点击下载:

have a nice day ~~~


欢迎关注我的微博:
http://weibo.com/u/3165018720


群号: 497438697 ,欢迎入群

微信公众号:hongyang钱柜娱乐开户
(欢迎关注,不要错过每一篇干货,支持投稿)

作者:lmj623565791 发表于2016/9/12 8:59:45 原文链接
阅读:14762 评论:27 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/52204039 /lmj623565791/article/details/52204039 lmj623565791 2016/8/15 8:36:59

本文已授权微信公众号:鸿洋(hongyang钱柜娱乐开户)在微信公众号平台原创首发。

转载请标明出处:
/lmj623565791/article/details/52204039
本文出自:【张鸿洋的博客】

一、概述

钱柜娱乐开户在support.v4包中为大家提供了两个非常神奇的类:

  • NestedScrollingParent
  • NestedScrollingChild

如果你从未听说过这两个类,没关系,听我慢慢介绍,你就明白这两个类可以用来干嘛了。相信大家都见识过或者使用过CoordinatorLayout,通过这个类可以非常便利的帮助我们完成一些炫丽的效果,例如下面这样的:

这样的效果就非常适合使用NestedScrolling机制去完成,并且CoordinatorLayout背后其实也是利用着这套机制,So,我相信你已经明白这套机制可以用来干嘛了。

但是,我相信你还有个问题

  • 这个机制相比传统的自定义ViewGroup事件分发处理有什么优越的地方吗?

恩,我们简单分析下:

按照上图:

假设我们按照传统的事件分发去理解,首先我们滑动的是下面的内容区域,而移动却是外部的ViewGroup在移动,所以按照传统的方式,肯定是外部的Parent拦截了内部的Child的事件;但是,上述效果图,当Parent滑动到一定程度时,Child又开始滑动了,中间整个过程是没有间断的。从正常的事件分发(不手动调用分发事件,不手动去发出事件)角度去做是不可能的,因为当Parent拦截之后,是没有办法再把事件交给Child的,事件分发,对于拦截,相当于一锤子买卖,只要拦截了,当前手势接下来的事件都会交给Parent(拦截者)来处理。

但是NestedScrolling机制来处理这个事情就很好办,所以对这个机制进行深入学习,一来有助于我们编写嵌套滑动时一些特殊的效果;二来是我为了对CoordinatorLayout做分析的铺垫~~~

ps:具体在哪个v4版本中添加的,就不去深究了,如果你的v4中没有上述两个类,升级下你的v4版本。NestedScrolling机制这个词,个人称呼,不清楚官方有没有这么叫,勿深究。

二、预期效果

当然讲解这两个类,肯定要有案例的支撑,不然太过于空洞了。好在,我这里有个非常好的案例可以来描述:

很久以前,我写过这样一篇文章:

完全按照传统的方式去编写的,而且为了连续滑动,做了一些非常特殊处理,比如手动去分发DOWN事件类的,有兴趣可以阅读下。

效果图是这样的:

今天我们就利用这个效果,作为NestedSroll机制的案例,最后我们还会简单分析一下源码,其实源码还是比较简单的~~

ps:CoordinatorLayout可以很方便实现该效果,后续的文章也会对CoordinateLayout做一些分析。

三、实现

上述效果图,分为3部分:顶部布局;中间的ViewPager指示器;以及底部的RecyclerView;

RecyclerView其实就是NestedSrollingChild的实现类,所以本例主要的角色是去实现NestedScrollingParent.

(1)布局文件

首先预览下布局文件,脑子里面有个大致的布局:

<com.zhy.view.StickyNavLayout xmlns:tools="http://schemas.钱柜娱乐开户.com/tools"
    xmlns:钱柜娱乐开户="http://schemas.钱柜娱乐开户.com/apk/res/钱柜娱乐开户"
    钱柜娱乐开户:layout_width="match_parent"
    钱柜娱乐开户:layout_height="match_parent"
    钱柜娱乐开户:orientation="vertical" >
    <RelativeLayout
        钱柜娱乐开户:id="@id/id_stickynavlayout_topview"
        钱柜娱乐开户:layout_width="match_parent"
        钱柜娱乐开户:layout_height="wrap_content"
        钱柜娱乐开户:background="#4400ff00" >
        <TextView
            钱柜娱乐开户:layout_width="match_parent"
            钱柜娱乐开户:layout_height="256dp"
            钱柜娱乐开户:gravity="center"
            钱柜娱乐开户:text="软件介绍"
            钱柜娱乐开户:textSize="30sp"
            钱柜娱乐开户:textStyle="bold" />
    </RelativeLayout>

    <com.zhy.view.SimpleViewPagerIndicator
        钱柜娱乐开户:id="@id/id_stickynavlayout_indicator"
        钱柜娱乐开户:layout_width="match_parent"
        钱柜娱乐开户:layout_height="50dp"
        钱柜娱乐开户:background="#ffffffff" >
    </com.zhy.view.SimpleViewPagerIndicator>

    <钱柜娱乐开户.support.v4.view.ViewPager
        钱柜娱乐开户:id="@id/id_stickynavlayout_viewpager"
        钱柜娱乐开户:layout_width="match_parent"
        钱柜娱乐开户:layout_height="match_parent"
         >
    </钱柜娱乐开户.support.v4.view.ViewPager>

</com.zhy.view.StickyNavLayout>

StickyNavLayout是直接继承自LinearLayout的,并且设置的是orientation="vertical",所以直观的就是控件按顺序纵向排列,至于测量需要做一些特殊的处理,因为不是本文的重点,可以自己查看源码,或者上面提到的文章。

(2) 实现NestedScrollingParent

NestedScrollingParent是一个接口,实现它需要实现如下方法:

public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes);

public void onNestedScrollAccepted(View child, View target, int nestedScrollAxes);

public void onNestedScroll(View target, int dxConsumed, int dyConsumed,
            int dxUnconsumed, int dyUnconsumed);

public void onNestedPreScroll(View target, int dx, int dy, int[] consumed);

public boolean onNestedFling(View target, float velocityX, float velocityY, boolean consumed);

public boolean onNestedPreFling(View target, float velocityX, float velocityY);

public int getNestedScrollAxes();

在写具体的实现前,先对需要用到的上述方法做一下简单的介绍:

  • onStartNestedScroll该方法,一定要按照自己的需求返回true,该方法决定了当前控件是否能接收到其内部View(非并非是直接子View)滑动时的参数;假设你只涉及到纵向滑动,这里可以根据nestedScrollAxes这个参数,进行纵向判断。
  • onNestedPreScroll该方法的会传入内部View移动的dx,dy,如果你需要消耗一定的dx,dy,就通过最后一个参数consumed进行指定,例如我要消耗一半的dy,就可以写consumed[1]=dy/2
  • onNestedFling你可以捕获对内部View的fling事件,如果return true则表示拦截掉内部View的事件。

主要关注的就是这三个方法~

这里内部View表示不一定非要是直接子View,只要是内部View即可。

下面看一下我们具体的实现:

public class StickyNavLayout extends LinearLayout implements NestedScrollingParent
{
    @Override
    public boolean onStartNestedScroll(View child, View target, int nestedScrollAxes)
    {
        return (nestedScrollAxes & ViewCompat.SCROLL_AXIS_VERTICAL) != 0;
    }
    @Override
    public void onNestedPreScroll(View target, int dx, int dy, int[] consumed)
    {
        boolean hiddenTop = dy > 0 && getScrollY() < mTopViewHeight;
        boolean showTop = dy < 0 && getScrollY() > 0 && !ViewCompat.canScrollVertically(target, -1);

        if (hiddenTop || showTop)
        {
            scrollBy(0, dy);
            consumed[1] = dy;
        }
    }
    @Override
    public boolean onNestedPreFling(View target, float velocityX, float velocityY)
    {
        if (getScrollY() >= mTopViewHeight) return false;
        fling((int) velocityY);
        return true;
    }
}
  • onStartNestedScroll中,我们判断了如果是纵向返回true,这个一般是需要内部的View去传入的,你要是不确定,或者担心内部View编写的不规范,你可以直接return true;
  • onNestedPreScroll中,我们判断,如果是上滑且顶部控件未完全隐藏,则消耗掉dy,即consumed[1]=dy;如果是下滑且内部View已经无法继续下拉,则消耗掉dy,即consumed[1]=dy,消耗掉的意思,就是自己去执行scrollBy,实际上就是我们的StickNavLayout滑动。
  • 此外,这里还处理了fling,通过onNestedPreFling方法,这个可以根据自己需求定了,当顶部控件显示时,fling可以让顶部控件隐藏或者显示。

以上代码就能实现下面的效果:

对于fling方法,我们利用了OverScroll的fling的方法,对于边界检测,是重写了scrollTo方法:

public void fling(int velocityY)
{
    mScroller.fling(0, getScrollY(), 0, velocityY, 0, 0, 0, mTopViewHeight);
    invalidate();
}

@Override
public void scrollTo(int x, int y)
{
    if (y < 0)
    {
        y = 0;
    }
    if (y > mTopViewHeight)
    {
        y = mTopViewHeight;
    }
    if (y != getScrollY())
    {
        super.scrollTo(x, y);
    }
}

详细的解释可以看上面提到的博客,这里就不重复了。

到这里呢,可以看到NestedScrolling机制说白了非常简单:

就是NestedScrollingParent内部的View,在滑动到时候,会首先将dx、dy传入给NestedScrollingParent,NestedScrollingParent可以决定是否对其进行消耗,一般会根据需求消耗部分或者全部(不过这里并没有实际的约束,你可以随便写消耗多少,可能会对内部View造成一定的影响)。

用白话和原本的事件分发机制作对比就是这样的(针对正常流程下一次手势):

  • 事件分发是这样的:子View首先得到事件处理权,处理过程中,父View可以对其拦截,但是拦截了以后就无法再还给子View(本次手势内)。
  • NestedScrolling机制是这样的:内部View在滚动的时候,首先将dx,dy交给NestedScrollingParent,NestedScrollingParent可对其进行部分消耗,剩余的部分还给内部View。

具体的源码会比本博文复杂,因为涉及到触摸非内部View区域的一些交互,非本博文重点,可以参考源码。

四、原理

原理其实就是看内部View什么时候回调NestedScrollingParent各种方法的,直接定位到内部View的onTouchEvent:

@Override
public boolean onTouchEvent(MotionEvent e) {
    switch (action) {
        case MotionEvent.ACTION_DOWN: {
            int nestedScrollAxis = ViewCompat.SCROLL_AXIS_NONE;
            if (canScrollHorizontally) {
                nestedScrollAxis |= ViewCompat.SCROLL_AXIS_HORIZONTAL;
            }
            if (canScrollVertically) {
                nestedScrollAxis |= ViewCompat.SCROLL_AXIS_VERTICAL;
            }
            startNestedScroll(nestedScrollAxis);
        } break;
        case MotionEvent.ACTION_MOVE: {
            if (dispatchNestedPreScroll(dx, dy, mScrollConsumed, mScrollOffset)) {
                dx -= mScrollConsumed[0];
                dy -= mScrollConsumed[1];
                vtev.offsetLocation(mScrollOffset[0], mScrollOffset[1]);
            }
        } break;
        case MotionEvent.ACTION_UP: {
            fling((int) xvel, (int) yvel);
            resetTouch();
        } break;

        case MotionEvent.ACTION_CANCEL: {
            cancelTouch();
        } break;
    }
    return true;
}

可以看到:

ACTION_DOWN调用了startNestedScroll;ACTION_MOVE中调用了dispatchNestedPreScroll;ACTION_UP可能会触发fling以调用resetTouch。

startNestedScroll内部实际上:

#NestedScrollingChildHelper
public boolean startNestedScroll(int axes) {
    if (hasNestedScrollingParent()) {
        // Already in progress
        return true;
    }
    if (isNestedScrollingEnabled()) {
        ViewParent p = mView.getParent();
        View child = mView;
        while (p != null) {
            if (ViewParentCompat.onStartNestedScroll(p, child, mView, axes)) {
                mNestedScrollingParent = p;
                ViewParentCompat.onNestedScrollAccepted(p, child, mView, axes);
                return true;
            }
            if (p instanceof View) {
                child = (View) p;
            }
            p = p.getParent();
        }
    }
    return false;
}

去寻找NestedScrollingParent,然后回调onStartNestedScroll和onNestedScrollAccepted。

dispatchNestedPreScroll中会回调onNestedPreScroll方法,内部的scrollByInternal中还会回调onNestedScroll方法。

fling中会回调onNestedPreFling和onNestedFling方法。

resetTouch中则会回调onStopNestedScroll。

代码其实没什么贴的,大家直接找到onTouchEvent一眼就能看到,调用的方法名都是dispatchNestedXXX方法,实际内部都是通过NestedScrollingChildHelper实现的。

所以如果你需要实现和NestedScrollingParent协作的内部View,记得实现NestedScrollingChild,然后内部借助NestedScrollingChildHelper这个辅助类,核心的方法都封装好了,你只需要在恰当的实际去传入参数调用方法即可。

ok,这样的一个机制一定要去试试,很多滑动相关的效果都可以借此实现;此外,后面可能会对CoordinatorLayout写一些博文~

源码地址:
https://github.com/hongyang钱柜娱乐开户/钱柜娱乐开户-StickyNavLayout


欢迎关注我的微博:
http://weibo.com/u/3165018720


群号: 497438697 ,欢迎入群

微信公众号:hongyang钱柜娱乐开户
(欢迎关注,不要错过每一篇干货,支持投稿)

作者:lmj623565791 发表于2016/8/15 8:36:59 原文链接
阅读:59568 评论:84 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/51931859 /lmj623565791/article/details/51931859 lmj623565791 2016/7/20 12:09:38

本文已在CSDN《程序员》杂志刊登。

本文已授权微信公众号:鸿洋(hongyang钱柜娱乐开户)在微信公众号平台原创首发。

转载请标明出处:
/lmj623565791/article/details/51931859
本文出自:【张鸿洋的博客】

一、概述

在钱柜娱乐开户应用开发中,我们常常为了提升开发效率会选择使用一些基于注解的框架,但是由于反射造成一定运行效率的损耗,所以我们会更青睐于编译时注解的框架,例如:

  • butterknife免去我们编写View的初始化以及事件的注入的代码。
  • EventBus3方便我们实现组建间通讯。
  • fragmentargs轻松的为fragment添加参数信息,并提供创建方法。
  • ParcelableGenerator可实现自动将任意对象转换为Parcelable类型,方便对象传输。

类似的库还有非常多,大多这些的库都是为了自动帮我们完成日常编码中需要重复编写的部分(例如:每个Activity中的View都需要初始化,每个实现Parcelable接口的对象都需要编写很多固定写法的代码)。

这里并不是说上述框架就一定没有使用反射了,其实上述其中部分框架内部还是有部分实现是依赖于反射的,但是很少而且一般都做了缓存的处理,所以相对来说,效率影响很小。

但是在使用这类项目的时候,有时候出现错误会难以调试,主要原因还是很多用户并不了解这类框架其内部的原理,所以遇到问题时会消耗大量的时间去排查。

那么,于情于理,在编译时注解框架这么火的时刻,我们有理由去学习:

  • 如何编写一个机遇编译时注解的项目

首先,是为了了解其原理,这样在我们使用类似框架遇到问题的时候,能够找到正确的途径去排查问题;其次,我们如果有好的想法,发现某些代码需要重复创建,我们也可以自己来写个框架方便自己日常的编码,提升编码效率;最后也算是自身技术的提升。

注:以下使用IDE为钱柜娱乐开户 Studio.

本文将以编写一个View注入的框架为线索,详细介绍编写此类框架的步骤。

二、编写前的准备

在编写此类框架的时候,一般需要建立多个module,例如本文即将实现的例子:

  • ioc-annotation 用于存放注解等,Java模块
  • ioc-compiler 用于编写注解处理器,Java模块
  • ioc-api 用于给用户提供使用的API,本例为Andriod模块
  • ioc-sample 示例,本例为Andriod模块

那么除了示例以为,一般要建立3个module,module的名字你可以自己考虑,上述给出了一个简单的参考。当然如果条件允许的话,有的开发者喜欢将存放注解和API这两个module合并为一个module。

对于module间的依赖,因为编写注解处理器需要依赖相关注解,所以:

ioc-compiler依赖ioc-annotation

我们在使用的过程中,会用到注解以及相关API

所以ioc-sample依赖ioc-api;ioc-api依赖ioc-annotation

三、注解模块的实现

注解模块,主要用于存放一些注解类,本例是模板butterknife实现View注入,所以本例只需要一个注解类:

@Retention(RetentionPolicy.CLASS)
@Target(ElementType.FIELD)
public @interface BindView
{
    int value();
}

我们设置的保留策略为Class,注解用于Field上。这里我们需要在使用时传入一个id,直接以value的形式进行设置即可。

你在编写的时候,分析自己需要几个注解类,并且正确的设置@Target以及@Retention即可。

四、注解处理器的实现

定义完成注解后,就可以去编写注解处理器了,这块有点复杂,但是也算是有章可循的。

该模块,我们一般会依赖注解模块,以及可以使用一个auto-service

build.gradle的依赖情况如下:

dependencies {
    compile 'com.google.auto.service:auto-service:1.0-rc2'
    compile project (':ioc-annotation')
}

auto-service库可以帮我们去生成META-INF等信息。

(1)基本代码

注解处理器一般继承于AbstractProcessor,刚才我们说有章可循,是因为部分代码的写法基本是固定的,如下:

@AutoService(Processor.class)
public class IocProcessor extends AbstractProcessor{
    private Filer mFileUtils;
    private Elements mElementUtils;
    private Messager mMessager;
    @Override
    public synchronized void init(ProcessingEnvironment processingEnv){
        super.init(processingEnv);
        mFileUtils = processingEnv.getFiler();
        mElementUtils = processingEnv.getElementUtils();
        mMessager = processingEnv.getMessager();
    }
    @Override
    public Set<String> getSupportedAnnotationTypes(){
        Set<String> annotationTypes = new LinkedHashSet<String>();
        annotationTypes.add(BindView.class.getCanonicalName());
        return annotationTypes;
    }
    @Override
    public SourceVersion getSupportedSourceVersion(){
        return SourceVersion.latestSupported();
    }
    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv){
    }

在实现AbstractProcessor后,process()方法是必须实现的,也是我们编写代码的核心部分,后面会介绍。

我们一般会实现getSupportedAnnotationTypes()getSupportedSourceVersion()两个方法,这两个方法一个返回支持的注解类型,一个返回支持的源码版本,参考上面的代码,写法基本是固定的。

除此以外,我们还会选择复写init()方法,该方法传入一个参数processingEnv,可以帮助我们去初始化一些父类类:

  • Filer mFileUtils; 跟文件相关的辅助类,生成JavaSourceCode.
  • Elements mElementUtils;跟元素相关的辅助类,帮助我们去获取一些元素相关的信息。
  • Messager mMessager;跟日志相关的辅助类。

这里简单提一下Elemnet,我们简单认识下它的几个子类,根据下面的注释,应该已经有了一个简单认知。

Element 
  - VariableElement //一般代表成员变量
  - ExecutableElement //一般代表类中的方法
  - TypeElement //一般代表代表类
  - PackageElement //一般代表Package

(2)process的实现

process中的实现,相比较会比较复杂一点,一般你可以认为两个大步骤:

  • 收集信息
  • 生成代理类(本文把编译时生成的类叫代理类)

什么叫收集信息呢?就是根据你的注解声明,拿到对应的Element,然后获取到我们所需要的信息,这个信息肯定是为了后面生成JavaFileObject所准备的。

例如本例,我们会针对每一个类生成一个代理类,例如MainActivity我们会生成一个MainActivity$$ViewInjector。那么如果多个类中声明了注解,就对应了多个类,这里就需要:

  • 一个类对象,代表具体某个类的代理类生成的全部信息,本例中为ProxyInfo
  • 一个集合,存放上述类对象(到时候遍历生成代理类),本例中为Map<String, ProxyInfo>,key为类的全路径。

这里的描述有点模糊没关系,一会结合代码就好理解了。

a.收集信息

private Map<String, ProxyInfo> mProxyMap = new HashMap<String, ProxyInfo>();
@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv){
    mProxyMap.clear();
    Set<? extends Element> elements = roundEnv.getElementsAnnotatedWith(BindView.class);
    //一、收集信息
    for (Element element : elements){
        //检查element类型
        if (!checkAnnotationUseValid(element)){
            return false;
        }
        //field type
        VariableElement variableElement = (VariableElement) element;
        //class type
        TypeElement typeElement = (TypeElement) variableElement.getEnclosingElement();//TypeElement
        String qualifiedName = typeElement.getQualifiedName().toString();

        ProxyInfo proxyInfo = mProxyMap.get(qualifiedName);
        if (proxyInfo == null){
            proxyInfo = new ProxyInfo(mElementUtils, typeElement);
            mProxyMap.put(qualifiedName, proxyInfo);
        }
        BindView annotation = variableElement.getAnnotation(BindView.class);
        int id = annotation.value();
        proxyInfo.mInjectElements.put(id, variableElement);
    }
    return true;
}

首先我们调用一下mProxyMap.clear();,因为process可能会多次调用,避免生成重复的代理类,避免生成类的类名已存在异常。

然后,通过roundEnv.getElementsAnnotatedWith拿到我们通过@BindView注解的元素,这里返回值,按照我们的预期应该是VariableElement集合,因为我们用于成员变量上。

接下来for循环我们的元素,首先检查类型是否是VariableElement.

然后拿到对应的类信息TypeElement,继而生成ProxyInfo对象,这里通过一个mProxyMap进行检查,key为qualifiedName即类的全路径,如果没有生成才会去生成一个新的,ProxyInfo与类是一一对应的。

接下来,会将与该类对应的且被@BindView声明的VariableElement加入到ProxyInfo中去,key为我们声明时填写的id,即View的id。

这样就完成了信息的收集,收集完成信息后,应该就可以去生成代理类了。

b.生成代理类

@Override
public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv){
    //...省略收集信息的代码,以及try,catch相关
    for(String key : mProxyMap.keySet()){
        ProxyInfo proxyInfo = mProxyMap.get(key);
        JavaFileObject sourceFile = mFileUtils.createSourceFile(
                proxyInfo.getProxyClassFullName(), proxyInfo.getTypeElement());
            Writer writer = sourceFile.openWriter();
            writer.write(proxyInfo.generateJavaCode());
            writer.flush();
            writer.close();
    }
    return true;
}

可以看到生成代理类的代码非常的简短,主要就是遍历我们的mProxyMap,然后取得每一个ProxyInfo,最后通过mFileUtils.createSourceFile来创建文件对象,类名为proxyInfo.getProxyClassFullName(),写入的内容为proxyInfo.generateJavaCode().

看来生成Java代码的方法都在ProxyInfo里面。

c.生成Java代码

这里我们主要关注其生成Java代码的方式。
下面主要看生成Java代码的方法:

#ProxyInfo
//key为id,value为对应的成员变量
public Map<Integer, VariableElement> mInjectElements = new HashMap<Integer, VariableElement>();

public String generateJavaCode(){
    StringBuilder builder = new StringBuilder();
    builder.append("package " + mPackageName).append(";\n\n");
    builder.append("import com.zhy.ioc.*;\n");
    builder.append("public class ").append(mProxyClassName).append(" implements " + SUFFIX + "<" + mTypeElement.getQualifiedName() + ">");
    builder.append("\n{\n");
    generateMethod(builder);
    builder.append("\n}\n");
    return builder.toString();
}
private void generateMethod(StringBuilder builder){
     builder.append("public void inject("+mTypeElement.getQualifiedName()+" host , Object object )");
    builder.append("\n{\n");
    for(int id : mInjectElements.keySet()){
        VariableElement variableElement = mInjectElements.get(id);
        String name = variableElement.getSimpleName().toString();
        String type = variableElement.asType().toString() ;

        builder.append(" if(object instanceof 钱柜娱乐开户.app.Activity)");
        builder.append("\n{\n");
        builder.append("host."+name).append(" = ");
        builder.append("("+type+")(((钱柜娱乐开户.app.Activity)object).findViewById("+id+"));");
        builder.append("\n}\n").append("else").append("\n{\n");
        builder.append("host."+name).append(" = ");
        builder.append("("+type+")(((钱柜娱乐开户.view.View)object).findViewById("+id+"));");
        builder.append("\n}\n");
    }
    builder.append("\n}\n");
}

这里主要就是靠收集到的信息,拼接完成的代理类对象了,看起来会比较头疼,不过我给出一个生成后的代码,对比着看会很多。

package com.zhy.ioc_sample;
import com.zhy.ioc.*;
public class MainActivity$$ViewInjector implements ViewInjector<com.zhy.ioc_sample.MainActivity>{
    @Override
    public void inject(com.zhy.sample.MainActivity host , Object object ){
        if(object instanceof 钱柜娱乐开户.app.Activity){
            host.mTv = (钱柜娱乐开户.widget.TextView)(((钱柜娱乐开户.app.Activity)object).findViewById(2131492945));
        }
        else{
            host.mTv = (钱柜娱乐开户.widget.TextView)(((钱柜娱乐开户.view.View)object).findViewById(2131492945));
        }
    }
}

这样对着上面代码看会好很多,其实就死根据收集到的成员变量(通过@BindView声明的),然后根据我们具体要实现的需求去生成java代码。

这里注意下,生成的代码实现了一个接口ViewInjector<T>,该接口是为了统一所有的代理类对象的类型,到时候我们需要强转代理类对象为该接口类型,调用其方法;接口是泛型,主要就是传入实际类对象,例如MainActivity,因为我们在生成代理类中的代码,实际上就是实际类.成员变量的方式进行访问,所以,使用编译时注解的成员变量一般都不允许private修饰符修饰(有的允许,但是需要提供getter,setter访问方法)。

这里采用了完全拼接的方式编写Java代码,你也可以使用一些开源库,来通过Java api的方式来生成代码,例如:

A Java API for generating .java source files.

到这里我们就完成了代理类的生成,这里任何的注解处理器的编写方式基本都遵循着收集信息、生成代理类的步骤。

五、API模块的实现

有了代理类之后,我们一般还会提供API供用户去访问,例如本例的访问入口是

 //Activity中
 Ioc.inject(Activity);
 //Fragment中,获取ViewHolder中
 Ioc.inject(this, view);

模仿了butterknife,第一个参数为宿主对象,第二个参数为实际调用findViewById的对象;当然在Actiivty中,两个参数就一样了。

  • API一般如何编写呢?

其实很简单,只要你了解了其原理,这个API就干两件事:

  • 根据传入的host寻找我们生成的代理类:例如MainActivity->MainActity$$ViewInjector
  • 强转为统一的接口,调用接口提供的方法。

这两件事应该不复杂,第一件事是拼接代理类名,然后反射生成对象,第二件事强转调用。

public class Ioc{
    public static void inject(Activity activity){
        inject(activity , activity);
    }
    public static void inject(Object host , Object root){
        Class<?> clazz = host.getClass();
        String proxyClassFullName = clazz.getName()+"$$ViewInjector";
       //省略try,catch相关代码 
        Class<?> proxyClazz = Class.forName(proxyClassFullName);
        ViewInjector viewInjector = (com.zhy.ioc.ViewInjector) proxyClazz.newInstance();
        viewInjector.inject(host,root);
    }
}
public interface ViewInjector<T>{
    void inject(T t , Object object);
}

代码很简单,拼接代理类的全路径,然后通过newInstance生成实例,然后强转,调用代理类的inject方法。

这里一般情况会对生成的代理类做一下缓存处理,比如使用Map存储下,没有再生成,这里我们就不去做了。

这样我们就完成了一个编译时注解框架的编写。

六、总结

本文通过具体的实例来描述了如何编写一个基于编译时注解的项目,主要步骤为:项目结构的划分、注解模块的实现、注解处理器的编写以及对外公布的API模块的编写。通过文本的学习应该能够了解基于编译时注解这类框架运行的原理,以及自己如何去编写这样一类框架。

*源码地址: https://github.com/hyman钱柜娱乐开户/ioc-apt-sample


欢迎关注我的微博:
http://weibo.com/u/3165018720


群号: 497438697 ,欢迎入群

微信公众号:hongyang钱柜娱乐开户
(欢迎关注,不要错过每一篇干货,支持投稿)

作者:lmj623565791 发表于2016/7/20 12:09:38 原文链接
阅读:28734 评论:41 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/51854533 /lmj623565791/article/details/51854533 lmj623565791 2016/7/8 8:19:40

本文已授权微信公众号:鸿洋(hongyang钱柜娱乐开户)在微信公众号平台原创首发。

转载请标明出处:
/lmj623565791/article/details/51854533
本文出自:【张鸿洋的博客】

1、概述

RecyclerView通过其高度的可定制性深受大家的青睐,也有非常多的使用者开始对它进行封装或者改造,从而满足越来越多的需求。

如果你对RecyclerView不陌生的话,你一定遇到过这样的情况,我想给RecyclerView加个headerView或者footerView,当你敲出.addHeaderView,你会发现并没有添加头部或者底部View的相关API。

那么本文主要的内容很明显了,完成以下工作:

  • 如何为RecyclerView添加HeaderView(支持多个)
  • 如何为RecyclerView添加FooterView(支持多个)
  • 如何让HeaderView或者FooterView适配各种LayoutManager

恩,其实本来我是想偷个懒的,因为Loader写过一篇类似的文章,文章见文末参考链接。但是我发现被别的公众号推送了~~

那我只能考虑自己换种思路来解决这个问题,并且提供尽可能多的功能了~

本文首发于我的公众号,欢迎扫码关注(二维码见左侧栏)。

2 思路

(1)原理

对于添加headerView或者footerView的思路

其实HeaderView实际上也是Item的一种,只不过显示在顶部的位置,那么我们完全可以通过为其设置ItemType来完成。

有了思路以后,我们心里就妥了,最起码我们的内心中想想是可以实现的,接下来考虑一些细节。

(2)一些细节

假设我们现在已经完成了RecyclerView的编写,忽然有个需求,需要在列表上加个HeaderView,此时我们该怎么办呢?

打开我们的Adapter,然后按照我们上述的原理,添加特殊的ViewType,然后修改代码完成。

这是比较常规的做法了,但是有个问题是,如果需要添加viewType,那么可能我们的Adapter需要修改的幅度就比较大了,比如getItemTypegetItemCountonBindViewHolderonCreateViewHolder等,几乎所有的方法都要进行改变。

这样来看,出错率是非常高的。

况且一个项目中可能多个RecyclerView都需要在其列表中添加headerView。

这么来看,直接改Adapter的代码是非常不划算的,最好能够设计一个类,可以无缝的为原有的Adapter添加headerView和footerView。

本文的思路是通过类似装饰者模式,去设计一个类,增强原有Adapter的功能,使其支持addHeaderViewaddFooterView。这样我们就可以不去改动我们之前已经完成的代码,灵活的去扩展功能了。

我希望的用法是这样的:

mHeaderAndFooterWrapper = new HeaderAndFooterWrapper(mAdapter);
t1.setText("Header 1");
TextView t2 = new TextView(this);
mHeaderAndFooterWrapper.addHeaderView(t2);

在不改变原有的Adapter基础上去增强其功能。

3、初步的实现

(1) 基本代码

public class HeaderAndFooterWrapper<T> extends RecyclerView.Adapter<RecyclerView.ViewHolder>
{
    private static final int BASE_ITEM_TYPE_HEADER = 100000;
    private static final int BASE_ITEM_TYPE_FOOTER = 200000;

    private SparseArrayCompat<View> mHeaderViews = new SparseArrayCompat<>();
    private SparseArrayCompat<View> mFootViews = new SparseArrayCompat<>();

    private RecyclerView.Adapter mInnerAdapter;

    public HeaderAndFooterWrapper(RecyclerView.Adapter adapter)
    {
        mInnerAdapter = adapter;
    }

    private boolean isHeaderViewPos(int position)
    {
        return position < getHeadersCount();
    }

    private boolean isFooterViewPos(int position)
    {
        return position >= getHeadersCount() + getRealItemCount();
    }


    public void addHeaderView(View view)
    {
        mHeaderViews.put(mHeaderViews.size() + BASE_ITEM_TYPE_HEADER, view);
    }

    public void addFootView(View view)
    {
        mFootViews.put(mFootViews.size() + BASE_ITEM_TYPE_FOOTER, view);
    }

    public int getHeadersCount()
    {
        return mHeaderViews.size();
    }

    public int getFootersCount()
    {
        return mFootViews.size();
    }
}

首先我们编写一个Adapter的子类,我们叫做HeaderAndFooterWrapper,然后再其内部添加了addHeaderView,addFooterView等一些辅助方法。

这里你可以看到,对于多个HeaderView,讲道理我们首先想到的应该是使用List<View>,而这里我们为什么要使用SparseArrayCompat<View>呢?

SparseArrayCompat有什么特点呢?它类似于Map,只不过在某些情况下比Map的性能要好,并且只能存储key为int的情况。

并且可以看到我们对每个HeaderView,都有一个特定的key与其对应,第一个headerView对应的是BASE_ITEM_TYPE_HEADER,第二个对应的是BASE_ITEM_TYPE_HEADER+1

为什么要这么做呢?

这两个问题都需要到复写onCreateViewHolder的时候来说明。

(2)复写相关方法

public class HeaderAndFooterWrapper<T> extends RecyclerView.Adapter<RecyclerView.ViewHolder>
{

    @Override
    public RecyclerView.ViewHolder onCreateViewHolder(ViewGroup parent, int viewType)
    {
        if (mHeaderViews.get(viewType) != null)
        {

            ViewHolder holder = ViewHolder.createViewHolder(parent.getContext(), mHeaderViews.get(viewType));
            return holder;

        } else if (mFootViews.get(viewType) != null)
        {
            ViewHolder holder = ViewHolder.createViewHolder(parent.getContext(), mFootViews.get(viewType));
            return holder;
        }
        return mInnerAdapter.onCreateViewHolder(parent, viewType);
    }

    @Override
    public int getItemViewType(int position)
    {
        if (isHeaderViewPos(position))
        {
            return mHeaderViews.keyAt(position);
        } else if (isFooterViewPos(position))
        {
            return mFootViews.keyAt(position - getHeadersCount() - getRealItemCount());
        }
        return mInnerAdapter.getItemViewType(position - getHeadersCount());
    }

    private int getRealItemCount()
    {
        return mInnerAdapter.getItemCount();
    }


    @Override
    public void onBindViewHolder(RecyclerView.ViewHolder holder, int position)
    {
        if (isHeaderViewPos(position))
        {
            return;
        }
        if (isFooterViewPos(position))
        {
            return;
        }
        mInnerAdapter.onBindViewHolder(holder, position - getHeadersCount());
    }

    @Override
    public int getItemCount()
    {
        return getHeadersCount() + getFootersCount() + getRealItemCount();
    }
}
  • getItemViewType

由于我们增加了headerView和footerView首先需要复写的就是getItemCount和getItemViewType。

getItemCount很好理解;

对于getItemType,可以看到我们的返回值是mHeaderViews.keyAt(position),这个值其实就是我们addHeaderView时的key,footerView是一样的处理方式,这里可以看出我们为每一个headerView创建了一个itemType。

  • onCreateViewHolder

可以看到,我们分别判断viewType,如果是headview或者是footerview,我们则为其单独创建ViewHolder,这里的ViewHolder是我之前写的一个通用的库里面的类,文末有链接。当然,你也可以自己写一个ViewHolder的实现类,只需要将对应的headerView作为itemView传入ViewHolder的构造即可。

这个方法中,我们就可以解答之前的问题了:

  • 为什么我要用SparseArrayCompat而不是List?
  • 为什么我要让每个headerView对应一个itemType,而不是固定的一个?

对于headerView假设我们有多个,那么onCreateViewHolder返回的ViewHolder中的itemView应该对应不同的headerView,如果是List,那么不同的headerView应该对应着:list.get(0),list.get(1)等。

但是问题来了,该方法并没有position参数,只有itemType参数,如果itemType还是固定的一个值,那么你是没有办法根据参数得到不同的headerView的。

所以,我利用SparseArrayCompat,将其key作为itemType,value为我们的headerView,在onCreateViewHolder中,直接通过itemType,即可获得我们的headerView,然后构造ViewHolder对象。而且我们的取值是从100000开始的,正常的itemType是从0开始取值的,所以正常情况下,是不可能发生冲突的。

需要说明的是,这里的意思并非是一定不能用List,通过一些特殊的处理,List也能达到上述我描述的效果。

  • onBindViewHolder

onBindViewHolder比较简单,发现是HeaderView或者FooterView直接return即可,因为对于头部和底部我们仅仅做展示即可,对于事件应该是在addHeaderView等方法前设置。

这样就初步完成了我们的装饰类,我们分别添加两个headerView和footerView:

我们看看运行效果:

感觉还是不错的,再不改动原来的Adapter的基础上,我们完成了初步的实现。

大家都知道RecyclerView比较强大,可以设置不同的LayoutManager,那么我们换成GridLayoutMananger再看看效果。

好像发现了不对的地方,我们的headerView果真被当成普通的Item处理了,不过由于我们的编写方式,出现上述情况是可以理解的。

那么我们该如何处理呢?让每个headerView独立的占据一行?

4、进一步的完善

好在RecyclerView里面为我们提供了一些方法。

(1)针对GridLayoutManager

在我们的HeaderAndFooterWrapper中复写onAttachedToRecyclerView方法,如下:

@Override
public void onAttachedToRecyclerView(RecyclerView recyclerView)
{
    innerAdapter.onAttachedToRecyclerView(recyclerView);

    RecyclerView.LayoutManager layoutManager = recyclerView.getLayoutManager();
    if (layoutManager instanceof GridLayoutManager)
    {
        final GridLayoutManager gridLayoutManager = (GridLayoutManager) layoutManager;
        final GridLayoutManager.SpanSizeLookup spanSizeLookup = gridLayoutManager.getSpanSizeLookup();

        gridLayoutManager.setSpanSizeLookup(new GridLayoutManager.SpanSizeLookup()
        {
            @Override
            public int getSpanSize(int position)
            {
               int viewType = getItemViewType(position);
              if (mHeaderViews.get(viewType) != null)
              {
                  return layoutManager.getSpanCount();
              } else if (mFootViews.get(viewType) != null)
              {
                  return layoutManager.getSpanCount();
              }
              if (oldLookup != null)
                  return oldLookup.getSpanSize(position);
              return 1;
            }
        });
        gridLayoutManager.setSpanCount(gridLayoutManager.getSpanCount());
    }
}

当发现layoutManager为GridLayoutManager时,通过设置SpanSizeLookup,对其getSpanSize方法,返回值设置为layoutManager.getSpanCount();

现在看一下运行效果:

哈,终于正常了。

(3)对于StaggeredGridLayoutManager

在刚才的代码中我们好像没有发现StaggeredGridLayoutManager的身影,StaggeredGridLayoutManager并没有setSpanSizeLookup这样的方法,那么该如何处理呢?

依然不复杂,重写onViewAttachedToWindow方法,如下:

@Override
public void onViewAttachedToWindow(RecyclerView.ViewHolder holder)
{
    mInnerAdapter.onViewAttachedToWindow(holder);
    int position = holder.getLayoutPosition();
    if (isHeaderViewPos(position) || isFooterViewPos(position))
    {
        ViewGroup.LayoutParams lp = holder.itemView.getLayoutParams();

        if (lp != null
                && lp instanceof StaggeredGridLayoutManager.LayoutParams)
        {

            StaggeredGridLayoutManager.LayoutParams p = 
                    (StaggeredGridLayoutManager.LayoutParams) lp;

            p.setFullSpan(true);
        }
    }
}

这样就完成了对StaggeredGridLayoutManager的处理,效果图就不贴了。

到此,我们就完成了整个HeaderAndFooterWrapper的编写,可以在不改变原Adapter代码的情况下,为其添加一个或者多个headerView或者footerView,以及完成了如何让HeaderView或者FooterView适配各种LayoutManager。

源码地址:
https://github.com/hongyang钱柜娱乐开户/baseAdapter


欢迎关注我的微博:
http://weibo.com/u/3165018720


群号: 497438697 ,欢迎入群

微信公众号:hongyang钱柜娱乐开户
(欢迎关注,不要错过每一篇干货,支持投稿)

参考

作者:lmj623565791 发表于2016/7/8 8:19:40 原文链接
阅读:81279 评论:111 查看评论
]]>
Hongyang - 钱柜娱乐开户 /lmj623565791/article/details/51635533 /lmj623565791/article/details/51635533 lmj623565791 2016/6/12 8:42:52

本文已授权微信公众号:鸿洋(hongyang钱柜娱乐开户)在微信公众号平台原创首发。

转载请标明出处:
/lmj623565791/article/details/51635533
本文出自:【张鸿洋的博客】

1、概述

上一篇文章,已经初步对钱柜娱乐开户 Studio的模板有了初步的介绍与使用,以及一些开源模板的推荐:

本文将对如何编写Template,进行详细的介绍(以activity摸版为例)。

2、模板的文件结构

学习编写模板最好的方式呢,就是参考IDE中已经提供的最简单的模板,那么在钱柜娱乐开户 Studio中最简单的activity模板就是:Empty Activity了,我们打开该模板文件,首先对文件结构有个直观的了解,如图:

可以看到每个插件对应一个文件夹,文件夹包含

  • template.xml
  • globals.xml.ftl
  • recipe.xml.ftl
  • root文件夹 存放对应源码的ftl文件,以及资源文件
  • 效果缩略图

下面我们逐一对上述每个文件的作用就行介绍。

2.1 template.xml

打开该文件,发现其主要内容如下:

<?xml version="1.0"?>
<template
    name="Empty Activity"
    minApi="7"
    minBuildApi="14"
    description="Creates a new empty activity">
    <category value="Activity" />
    <parameter
        id="activityClass"
        name="Activity Name"
        type="string"
        constraints="class|unique|nonempty"
        suggest="${layoutToActivity(layoutName)}"
        default="MainActivity"
        help="The name of the activity class to create" />

    <thumbs>
        <!-- default thumbnail is required -->
        <thumb>template_blank_activity.png</thumb>
    </thumbs>

    <globals file="globals.xml.ftl" />
    <execute file="recipe.xml.ftl" />
</template>

其中

  • <template>中的name属性,对应新建Activity时显示的名字
  • <category>对应New的类别为Activity

剩下的,对应我们钱柜娱乐开户Studio新建Empty Activity的界面就非常好理解了,如图:

看到这个界面,大部分属性都应该能才出来了,我们重点看parameter,界面上每一个紫色框出来的部分都对应一个parameter,部分属性介绍:

  • id :唯一标识,最终通过该属性的值,获取用户输入值(文本框内容,是否选中)
  • name:界面上的类似label的提示语
  • type : 输入值类型
  • constraints:填写值的约束
  • suggest:建议值,比如填写ActivityName的时候,会给出一个布局文件的建议值。
  • default:默认值
  • help:底部显示的提升语

这个部分对应界面还是非常好理解的,大家可以简单的修改一些字符串,或者添加一个<parameter>,重启AS,看看效果。

template.xml的最下面的部分引入了globals.xml.ftlrecipe.xml.ftl

这两个我们会详细介绍。

2.2 globals.xml.ftl

<?xml version="1.0"?>
<globals>
    <global id="hasNoActionBar" type="boolean" value="false" />
    <#include "../common/common_globals.xml.ftl" />
</globals>

通过名称可以猜到它是用于定义一些全局的变量,可以看到其内部有<global>标签,分别定义id,type,默认值。

同理,我们可以通过id的值访问到该值,例如:

${hasNoActionBar}的值为false。

2.3 recipe.xml.ftl

<recipe>

<copy from="root/res/drawable-hdpi"
            to="${escapeXmlAttribute(resOut)}/drawable-hdpi" />

<merge from="root/${resIn}/values/strings.xml.ftl"
             to="${escapeXmlAttribute(resOut)}/values/strings.xml" />

<instantiate from="root/src/app_package/SimpleActivity.java.ftl"
               to="${escapeXmlAttribute(srcOut)}/${activityClass}.java" />

<open file="${escapeXmlAttribute(srcOut)}/${activityClass}.java" />

</recipe>

为了介绍,我将该xml中比较重要的几个标签都列出来了:

  • copy :从root中copy文件到我们的目标目录,比如我们的模板Activity需要使用一些图标,那么可能就需要使用copy标签将这些图标拷贝到我们的项目对应文件夹。
  • merge : 合并的意思,比如将我们使用到的strings.xml合并到我们的项目的stirngs.xml中
  • instantiate : 和copy类似,但是可以看到上例试将ftl->java文件的,也就是说中间会通过一个步骤,将ftl中的变量都换成对应的值,那么完整的流程是ftl->freemarker process -> java
  • open:在代码生成后,打开指定的文件,比如我们新建一个Activity后,默认就会将该Activity打开。

在介绍instantiate时,涉及到了freemarker,不可避免的需要对它进行简单的介绍。

目前我们已经基本了解了一个模板其内部的文件结构了,以及每个文件大致包含的东西,我们简单做个总结:

  • template 中parameter标签,主要用于提供参数
  • global.xml.ftl 主要用于提供参数
  • recipe.xml.ftl 主要用于生成我们实际需要的代码,资源文件等;例如,利用参数+MainActivity.java.ftl -> MainActivity.java;其实就是利用参数将ftl中的变量进行替换。

那么整体的关系类似下图:

图片来源:http://www.slideshare.net/murphonic/custom-钱柜娱乐开户-code-templates-15537501

3、简单的freemarker语法

上面我们已经基本了解模板生成的大致的流程以及涉及到的文件,大致了解了我们生成的源码或者xml文件,需要经过:

  • ftl->freemarker process->java/xml

这样的流程,那么我们必须对freemarker有个简单的了解。

  • 非常简单的例子

其实非常简单:

比如我们有个变量user=zhy;
有个ftl文件内容:helloL${user}
最后经过freemarker的输出结果即为 hello:zhy
  • if语法
<#if generateLayout>
    //生成layout文件
</#if>

看一眼就知道大概的意思了~有一定的编程经验,即使不知道这个叫freemarker,对于这些简单的语法还是能看懂的。

我们最后以Empty Activity模板的中的SimpleActivity为例:

root/src/app_package/SimpleActivity.java.ftl

package ${packageName};

import ${superClassFqcn};
import 钱柜娱乐开户.os.Bundle;

public class ${activityClass} extends ${superClass} {

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
<#if generateLayout>
        setContentView(R.layout.${layoutName});
</#if>
    }
}

可以看到其内部包含很多变量,这些变量的值一般来源于用户输入和global.xml.ftl中预定义的值,经过recipe.xml.ftl中instantiate标签的处理,将变量换成实际的值,即可在我们的项目的指定位置,得到我们期望的Activity。

流程大致可用下图说明:

图片来源:http://www.slideshare.net/murphonic/custom-钱柜娱乐开户-code-templates-15537501

看到这,最起码理解了,当我们选择创建不同的Activity类型,最终得到的不同的效果其中的原理原来在这。

4、具体的模板实例

了解了基本的理论之后,下面我们可以通过一个实例来将上面的知识点整合。

我们编写一个Activity模板叫做:ViewPagerWithTabActivity,用于创建一个携带TabLayout的ViewPager,效果如下:

当我们点击New->Activity->ViewPagerWithTabActivity 就可能完成上面的Activity的创建,而避免了编写布局文件,引入design库,以及一些简单的编码。

是不是感觉还是不错的,大家可以针对自己的需求,按照规范的格式随意定制模板。

建议大家copy一个现有的模板,再其基础上修改即可,比如本例是在Empty Activity基础之上修改的。

下面我们看上例的具体的实现。

4.1 template.xml的编写

通过上面的学习我们知道template.xml中可以定义我们创建面板的控件布局等,本例我们创建Activity的界面如下:

对应的template.xml如下:

<?xml version="1.0"?>
<template>

    <category value="Activity" />
    <formfactor value="Mobile" />

    <parameter id="activityClass"/>
    <parameter id="activityLayoutName"/>
    <parameter id="tabCount"/>
    <parameter id="isLauncher"/>

    <parameter id="packageName"/>
    <!-- 128x128 thumbnails relative to template.xml -->
    <thumbs>
        <!-- default thumbnail is required -->
        <thumb>template_vp_with_tab_activity.png</thumb>
    </thumbs>

    <globals file="globals.xml.ftl" />
    <execute file="recipe.xml.ftl" />

</template>

经过前面的学习应该很好理解,每个parameter对应界面上的一个控件,控件的这个id最终可以得到用户输入值,后面会用于渲染ftl文件。

4.2 用到的类

本例中最终需要生成Fragment和Activity,也就是说对应会有两个ftl文件分别用于最终生成这两个类。

  • root/src/app_package/MainActivity.java.ftl
package  ${packageName};

import 钱柜娱乐开户.os.Bundle;
import 钱柜娱乐开户.support.design.widget.TabLayout;
import 钱柜娱乐开户.support.v4.app.Fragment;
import 钱柜娱乐开户.support.v4.app.FragmentPagerAdapter;
import 钱柜娱乐开户.support.v4.view.ViewPager;
import 钱柜娱乐开户.support.v7.app.AppCompatActivity;

import ${packageName}.fragment.SimpleFragment;

public class ${activityClass} extends AppCompatActivity
{
    private TabLayout mTabLayout;
    private ViewPager mViewPager;
    private int mTabCount = ${tabCount};

    @Override
    protected void onCreate(Bundle savedInstanceState)
    {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.${activityLayoutName});

        mTabLayout = (TabLayout) findViewById(R.id.id_tablayout);
        mViewPager = (ViewPager) findViewById(R.id.id_viewpager);

        mViewPager.setAdapter(new FragmentPagerAdapter(getSupportFragmentManager())
        {
            @Override
            public Fragment getItem(int position)
            {
                return new SimpleFragment();
            }
            @Override
            public int getCount()
            {
                return mTabCount;
            }
            @Override
            public CharSequence getPageTitle(int position)
            {
                return "Tab:" + position;
            }
        });

        mTabLayout.setupWithViewPager(mViewPager);
    }
}

注意不是.java文件而是.ftl文件,可以看到上面的代码基础上和Java代码没什么区别,实际上就是Java代码,把可变的部分都换成了${变量名}的方式而已。

例如:类名是用户填写的,我们就使用${activityClass}替代,其他同理。

  • root/src/app_package/SimpleFragment.java.ftl
package ${packageName}.fragment;

import 钱柜娱乐开户.os.Bundle;
import 钱柜娱乐开户.support.annotation.Nullable;
import 钱柜娱乐开户.support.v4.app.Fragment;
import 钱柜娱乐开户.view.Gravity;
import 钱柜娱乐开户.view.LayoutInflater;
import 钱柜娱乐开户.view.View;
import 钱柜娱乐开户.view.ViewGroup;
import 钱柜娱乐开户.widget.TextView;

/**
 * Created by zhy on 16/6/6.
 */
public class SimpleFragment extends Fragment
{
    @Nullable
    @Override
    public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState)
    {
        TextView tv = new TextView(getActivity());
        tv.setGravity(Gravity.CENTER);
        tv.setTextSize(40);
        tv.setText("just test");
        return tv ;
    }
}

这个类更简单,除了package是动态的,其他都写好了,主要用于作为ViewPager的Fragment Item.

4.3 用到的布局文件

  • root/res/layout/activity_main.xml.ftl
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:钱柜娱乐开户="http://schemas.钱柜娱乐开户.com/apk/res/钱柜娱乐开户"
    xmlns:tools="http://schemas.钱柜娱乐开户.com/tools"
    钱柜娱乐开户:layout_width="match_parent"
    钱柜娱乐开户:layout_height="match_parent"
    钱柜娱乐开户:orientation="vertical"
    tools:context=" ${packageName}.${activityClass}">

    <钱柜娱乐开户.support.design.widget.TabLayout
        钱柜娱乐开户:id="@+id/id_tablayout"
        钱柜娱乐开户:layout_width="match_parent"
        钱柜娱乐开户:layout_height="wrap_content">
    </钱柜娱乐开户.support.design.widget.TabLayout>

    <钱柜娱乐开户.support.v4.view.ViewPager
        钱柜娱乐开户:id="@+id/id_viewpager"
        钱柜娱乐开户:layout_width="match_parent"
        钱柜娱乐开户:layout_height="0dp"
        钱柜娱乐开户:layout_weight="1"
    />
</LinearLayout>

发现和我们真正编写的Activity并无多大区别。

看完用到的类和布局文件的ftl,大家心里应该有个底了,这模板几乎就和我们平时写的Java类一样,只是根据用户在新建Actiivty界面所输入的参数进行替换一些变量或者做一些简单的操作而已。

4.4 recipe.xml.ftl的编写

除了template.xml还有globals.xml.ftl和recipe.xml.ftl,globals.xml.ftl中基本上没有修改任何内容就不介绍了。

recipe.xml.ftl中定义的东西比较关键,例如将ftl->java,copy、合并资源文件等。

内容较长,我们拆开描述。

  • 引入依赖
<?xml version="1.0"?>
<recipe>
    <#include "../common/recipe_manifest.xml.ftl" />

    <#if !appCompat && !(hasDependency('com.钱柜娱乐开户.support:support-v4'))>
            <dependency mavenUrl="com.钱柜娱乐开户.support:support-v4:${buildApi}.+"/>
     </#if>

    <#if appCompat && !(hasDependency('com.钱柜娱乐开户.support:appcompat-v7'))>
           <dependency mavenUrl="com.钱柜娱乐开户.support:appcompat-v7:${buildApi}.+" />
    </#if>

    <#if (buildApi gte 22) && appCompat && !(hasDependency('com.钱柜娱乐开户.support:design'))>
        <dependency mavenUrl="com.钱柜娱乐开户.support:design:${buildApi}.+" />
    </#if>

//省略其他
</recipe>

本例依赖v4、v7、和design库,我们需要在这里定义引入;

你可能会问,这个引入的代码看起来挺复杂,你怎么知道这样写呢?

其实我也不知道怎么写,但是我可以打开IDE自带模板参考参考,copy过来就好了。

  • 剩下的内容
<?xml version="1.0"?>
<recipe>
  //省略依赖
    <instantiate from="root/src/app_package/MainActivity.java.ftl"
        to="${escapeXmlAttribute(srcOut)}/${activityClass}.java" />

    <instantiate from="root/src/app_package/SimpleFragment.java.ftl"
        to="${escapeXmlAttribute(srcOut)}/fragment/SimpleFragment.java" />   

    <instantiate from="root/res/layout/activity_main.xml.ftl"
        to="${escapeXmlAttribute(resOut)}/layout/${activityLayoutName}.xml" />

    <open file="${escapeXmlAttribute(resOut)}/layout/${activityLayoutName}.xml"/>        

    <open file="${escapeXmlAttribute(srcOut)}/${activityClass}.java" />
</recipe>

可以看到包含多个instantiate标签,该标签很明显是将我们内置的ftl转化为当前项目有中的java类。

上例,转化了

  • ${activityClass}.java
  • fragment/SimpleFragment.java"
  • /layout/${activityLayoutName}.xml

剩下是两个open标签,主要就是用于新建完成后,自动打开该文件。

本例新建完成后,Activity和其对应的布局文件都会自动打开。

恩,这里没用到merge标签,不过也很简单,假设你fragment上显示的文本,你可以定义到一个strings.xml里面,最后你需要将这个strings.xml合并到当前项目的strings.xml就可以使用merge标签(内置模板很多用了merge标签,参考下,抄一抄就搞定了)。

ok,到这,我们整个模板的编写介绍就结束了。

5、总结

本文我们首先详细介绍了一个模板文件夹下各个文件以及其内部的标签的作用,然后通过一个具体的实例,来演示如何编写一个activity模板。

如果你看的足够仔细,再花点时间动手,根据需求编写几个模板应该是不成问题的。

当然,文中一些细节并没有谈到,对于这些不要担心,你有什么需求,你就想哪个内置的模板好像有类似的需求,看它的实现,copy它的相关代码改一改就好了,没有必要去被各种文件的编写,这种东西copy修改就好了。

测试过程中,需要重启钱柜娱乐开户 Studio,如果有问题,记得查看Event Log面板的信息。

此外,模板这个东西我觉得最好的集大家的力量,所以我在github建立了一个组织仓库,https://github.com/Wan钱柜娱乐开户/钱柜娱乐开户StudioTemplates,如果你对模板有兴趣或者想要将自己的模板文件与他人共享,可以加入这个组织,然后分享你的代码,本文的例子也在其中。


欢迎关注我的微博:
http://weibo.com/u/3165018720


群号: 497438697 ,欢迎入群

微信公众号:hongyang钱柜娱乐开户
(欢迎关注,不要错过每一篇干货,支持投稿)

参考链接

作者:lmj623565791 发表于2016/6/12 8:42:52 原文链接
阅读:61833 评论:36 查看评论
]]>