Android属性动画基础之流程解析

综合技术 2018-05-24 阅读原文

本文将对属性动画执行流程做初步解析,由于属性动画是一套比较复杂的系统,无法面面俱到,仅做基本流程解析

先看一下基本执行流程示意图

上述示意图,仅粗略的描述了核心逻辑,部分细节尚未给出,下面我们从ValueAnimator的start()方法开始追踪源码,了解其执行流程,并还原部分细节

代码片段一—— ValueAnimator

1   public void start() {
2       start(false);
3   }

4   private void start(boolean playBackwards) {
5        if (Looper.myLooper() == null) {
6            throw new AndroidRuntimeException("Animators may only be run on Looper threads");
7        }
8        mReversing = playBackwards;
9        // Special case: reversing from seek-to-0 should act as if not seeked at all.
10        if (playBackwards && mSeekFraction != -1 && mSeekFraction != 0) {
11            if (mRepeatCount == INFINITE) {
12                // Calculate the fraction of the current iteration.
13                float fraction = (float) (mSeekFraction - Math.floor(mSeekFraction));
14                mSeekFraction = 1 - fraction;
15            } else {
16                mSeekFraction = 1 + mRepeatCount - mSeekFraction;
17            }
18        }
19        mStarted = true;
20        mPaused = false;
21        mRunning = false;
22        // Resets mLastFrameTime when start() is called, so that if the animation was running,
23        // calling start() would put the animation in the
24        // started-but-not-yet-reached-the-first-frame phase.
25        mLastFrameTime = 0;
          // 关键点、关键点、关键点
26        AnimationHandler animationHandler = AnimationHandler.getInstance();
27        animationHandler.addAnimationFrameCallback(this, (long) (mStartDelay * sDurationScale));
          // 关键点、关键点、关键点
28        if (mStartDelay == 0 || mSeekFraction >= 0) {
29            // If there's no start delay, init the animation and notify start listeners right away
30            // to be consistent with the previous behavior. Otherwise, postpone this until the first
31            // frame after the start delay.
          // 关键点、关键点、关键点
32            startAnimation();
33            if (mSeekFraction == -1) {
34                // No seek, start at play time 0. Note that the reason we are not using fraction 0
35                // is because for animations with 0 duration, we want to be consistent with pre-N
36                // behavior: skip to the final value immediately.
                  // 关键点、关键点、关键点
37                setCurrentPlayTime(0);
38            } else {
                  // 关键点、关键点、关键点
39                setCurrentFraction(mSeekFraction);
40            }
41        }
42    }

先定位到 第2行 ,调用start()方法后,其实是调用了私有方法start(boolean playBackwards)进行相关状态初始化操作,传入参数为false。继续查看start(boolean playBackwards)方法 。 19至21行 对动画状态做了初始化。 第26行 获取了AnimationHandler实例,该实例是属性动画系统唯一实例,您可继续深入查看源码来印证,AnimationHandler维护一个AnimationHandler.AnimationFrameCallback接口实例列表来统一管理所有属性动画。而ValueAnimator实现了AnimationHandler.AnimationFrameCallback接口,因此, 第27行 实际就是将自身加入到了回调列表。

继续看 第28行 条件判断语句,mStartDelay为延迟启动时间,mSeekFraction是我们通过setCurrentFraction(float fraction)方法设置的动画整体进度。默认值为-1,通常我们并不会主动使用。如果我们没有设置延迟启动或设置了动画执行进度(大于0),则条件满足,会触发startAnimation()方法。判断条件满足情况下,若我们没有设置动画进度,会触发setCurrentPlayTime方法,若设置则再次触发setCurrentFraction方法。这部分细节请看代码片段二

代码片段二—— ValueAnimator

1    private void startAnimation() {
2        if (Trace.isTagEnabled(Trace.TRACE_TAG_VIEW)) {
3            Trace.asyncTraceBegin(Trace.TRACE_TAG_VIEW, getNameForTrace(),
4                    System.identityHashCode(this));
5        }
6        mAnimationEndRequested = false;
          // 关键点、关键点、关键点
7        initAnimation();
8        mRunning = true;
9        if (mSeekFraction >= 0) {
10            mOverallFraction = mSeekFraction;
11        } else {
12            mOverallFraction = 0f;
13        }
14        if (mListeners != null) {
              // 关键点、关键点、关键点
15            notifyStartListeners();
16        }
17    }

18    // initAnimation()方法
19    void initAnimation() {
20        if (!mInitialized) {
21            int numValues = mValues.length;
22            for (int i = 0; i < numValues; ++i) {
23                mValues[i].init();
24            }
25            mInitialized = true;
26        }
27    }

28    private void notifyStartListeners() {
29        if (mListeners != null && !mStartListenersCalled) {
30            ArrayList tmpListeners =
31                    (ArrayList) mListeners.clone();
32            int numListeners = tmpListeners.size();
33            for (int i = 0; i  0 ? (float) playTime / mDuration : 1;
41        setCurrentFraction(fraction);
42    }

43    public void setCurrentFraction(float fraction) {
44        initAnimation();
45        fraction = clampFraction(fraction);
46        long seekTime = (long) (getScaledDuration() * fraction);
47        long currentTime = AnimationUtils.currentAnimationTimeMillis();
48        mStartTime = currentTime - seekTime;
49        mStartTimeCommitted = true; // do not allow start time to be compensated for jank
50        if (!isPulsingInternal()) {
51            // If the animation loop hasn't started, the startTime will be adjusted in the first
52            // frame based on seek fraction.
53            mSeekFraction = fraction;
54        }
55        mOverallFraction = fraction;
56        final float currentIterationFraction = getCurrentIterationFraction(fraction);
57        animateValue(currentIterationFraction);
58    }

先看一下代码片段中startAnimation方法,定位到 第7行 ,调用了initAnimation方法对动画值value进行(PropertyValuesHolder,无论我们通过什么方法构造动画,最终我们所操纵的值或对象属性都会被封装为PropertyValuesHolder,通过PropertyValuesHolder计算更新数值或对象属性)初始化,稍后我们看一下PropertyValuesHolder的init()方法,以便了解一下 TypeEvaluator 。定位到 第15行 ,跳进notifyStartListeners方法内看一看,该方法会检测是否设置了AnimatorListener,若设置,则回调其onAnimationStart方法。

回看 代码片段一 第37和39行,setCurrentPlayTime方法内部会调用setCurrentFraction方法,而setCurrentFraction方法会调用animateValue来进行相关计算更新并做相关更新回调,animateValue后续会做进一步说明,您可自行查看 代码片段二 39至58行来了解setCurrentPlayTime与setCurrentFraction方法,比较简单,除了调用animateValue方法外,就是计算整体进度mOverallFraction ( 第55行 )及当前动画周期内执行进度currentIterationFraction ( 第56行 )。注意区分mOverallFraction 和currentIterationFraction ,mOverallFraction是动画从执行到现在所经历的时间除以动画周期的值,与动画是否重复执行有关,若您设置了动画可重复执行,该值可能大于1;而currentIterationFraction不可能大于1,该值描述的是动画当前执行周期内的时间进度,与动画RepeatMode及是否设置重复执行有关,您可自行查阅代码,该值用于动画计算当前数值或属性值。

对代码片段一和代码片段二进行一下归总梳理,调用start方法后,核心逻辑及方法如下:

1.先对动画相关状态进行更新。

2.将自身添加到AnimationHandler中的回调列表

3.检测是否是延迟执行和是否主动设置了动画进度。若没有设置延迟执行或主动设置了动画进度,则通过调用startAnimation方法来完成动画值初始化;若我们设置了AnimatorListener则回调AnimatorListener的onAnimationStart方法通知我们动画已经开始,然后计算动画值(animateValue方法),并且若设置了AnimatorUpdateListener则进行回调通知我们动画已经更新

涉及的几个核心方法如下:

start(boolean playBackwards);

startAnimation(); ——>initAnimation() & notifyStartListeners()

setCurrentPlayTime(long playTime);

setCurrentFraction(float fraction);

animateValue(float fraction);

现在我们回看initAnimation,定位到上述代码片段 第23行 ,我们已经说过,动画值其实是PropertyValuesHolder,我们看一下其init()方法

void init() {
        if (mEvaluator == null) {
            // We already handle int and float automatically, but not their Object
            // equivalents
            mEvaluator = (mValueType == Integer.class) ? sIntEvaluator :
                    (mValueType == Float.class) ? sFloatEvaluator :
                    null;
        }
        if (mEvaluator != null) {
            // KeyframeSet knows how to evaluate the common types - only give it a custom
            // evaluator if one has been set on this class
            mKeyframes.setEvaluator(mEvaluator);
        }
    }

通过查看上述代码,很明显,我们通过ofInt、ofFloat、ofArgb方法构建的动画,系统会帮我们设置相匹配的TypeEvaluator,TypeEvaluator是很有用的辅助类,通过我们自定义的TypeEvaluator,我们可以按照自己的需求来计算更新动画值。

目前为止,我们已经给了解start()方法执行的基本核心逻辑,现在我们按照流程示意图顺序看一下AnimationHandler,回看 代码片段26和27行 。第26行获取AnimationHandler实例,第27行将ValueAnimator加入到AnimationHandler 回调列表,看一下相关代码片段

代码片段三—— AnimationHandler

1    public final static ThreadLocal sAnimatorHandler = new ThreadLocal();
2    private final ArrayList mAnimationCallbacks = new ArrayList();
3    private final ArrayMap mDelayedCallbackStartTime = new ArrayMap();
4    private AnimationFrameCallbackProvider mProvider;
5    private final Choreographer.FrameCallback mFrameCallback = new Choreographer.FrameCallback() {
        /**
         * Called when a new display frame is being rendered.
         * @param frameTimeNanos The time in nanoseconds when the frame started being rendered
         */
6        @Override
7        public void doFrame(long frameTimeNanos) {
8            doAnimationFrame(getProvider().getFrameTime());
9            if (mAnimationCallbacks.size() > 0) {
10                getProvider().postFrameCallback(this);
11            }
12        }
13    };

14    public static AnimationHandler getInstance() {
15        if (sAnimatorHandler.get() == null) {
16            sAnimatorHandler.set(new AnimationHandler());
17        }
18        return sAnimatorHandler.get();
19    }
 
20    public void addAnimationFrameCallback(final AnimationFrameCallback callback, long delay) {
21        if (mAnimationCallbacks.size() == 0) {
22            getProvider().postFrameCallback(mFrameCallback);
23        }
24        if (!mAnimationCallbacks.contains(callback)) {
25            mAnimationCallbacks.add(callback);
26        }

27        if (delay > 0) {
28            mDelayedCallbackStartTime.put(callback, (SystemClock.uptimeMillis() + delay));
29        }
30    }

31    private AnimationFrameCallbackProvider getProvider() {
32        if (mProvider == null) {
33            mProvider = new MyFrameCallbackProvider();
34        }
35        return mProvider;
36    }

    /**
     * Default provider of timing pulse that uses Choreographer for frame callbacks.
     */
37    private class MyFrameCallbackProvider implements AnimationFrameCallbackProvider {
38        final Choreographer mChoreographer = Choreographer.getInstance();
39        @Override
40        public void postFrameCallback(Choreographer.FrameCallback callback) {
41            mChoreographer.postFrameCallback(callback);
42        }

43        @Override
44        public void postCommitCallback(Runnable runnable) {
45            mChoreographer.postCallback(Choreographer.CALLBACK_COMMIT, runnable, null);
46        }

47        @Override
48        public long getFrameTime() {
49            return mChoreographer.getFrameTime();
50        }

51        @Override
52        public long getFrameDelay() {
53            return Choreographer.getFrameDelay();
54        }

55        @Override
56        public void setFrameDelay(long delay) {
57            Choreographer.setFrameDelay(delay);
58        }
59    }

60    private void doAnimationFrame(long frameTime) {
61        int size = mAnimationCallbacks.size();
62        long currentTime = SystemClock.uptimeMillis();
63        for (int i = 0; i < size; i++) {
64            final AnimationFrameCallback callback = mAnimationCallbacks.get(i);
65            if (callback == null) {
66                continue;
67            }
68            if (isCallbackDue(callback, currentTime)) {
69                callback.doAnimationFrame(frameTime);
                    // 动画延迟执行机制。根据动画延迟执行时间与当前时间来决定是否更新动画,若还没过延迟时间则不会更新
70                if (mCommitCallbacks.contains(callback)) {
71                    getProvider().postCommitCallback(new Runnable() {
72                        @Override
73                        public void run() {
74                            commitAnimationFrame(callback, getProvider().getFrameTime());
75                        }
76                    });
77                }
78            }
79        }
80        cleanUpList();
81    }

     /**
     * Implement this interface to receive a callback when a new display frame is
     * being rendered.  The callback is invoked on the {@link Looper} thread to
     * which the {@link Choreographer} is attached.
     */
    public interface FrameCallback {
        /**
         * Called when a new display frame is being rendered.
         * ......
         * ......
         */
        public void doFrame(long frameTimeNanos);
    }

之前我们说过,AnimationHandler 实例为动画系统唯一实例,看一下getInstance方法及第1行,显然可以印证这一点。接下来,我们看一下addAnimationFrameCallback方法,先看第25行: 将动画加入到回调列表 ;再看第28行: 如果是延迟执行动画会被加入到延迟回调列表 。现在回过头来看第21和22行,首先是条件判断,若回调列表为空,才执行第22行操作,但是为什么做判断,getProvider()方法get的是什么东西,postFrameCallback方法做了什么,mFrameCallback又是何物?

不按套路出牌,现在回看第5至13行,了解一下 mFrameCallback ,mFrameCallback是 Choreographer.FrameCallback 接口实例,Choreographer.FrameCallback只有一个doFrame方法,我们看一下该接口源码及api( 第81行之后的部分 )。接口api描述翻译一下: 当一个新的动画帧需要渲染更新的时候,该接口实例会接收到相关回调 ,额外的,api还说了,回调必须发生在Looper线程,所以您别试图在一个非Looper线程执行动画。好,继续看一下doFrame方法api描述,摘取核心内容: 当一个新的动画帧需要渲染更新时回调doFrame方法 。而Choreographer.FrameCallback的实例化对象mFrameCallback会在doFrame方法触发后调用 doAnimationFrame(long frameTime)方法( 参见第8行 )。我们看一下doAnimationFrame方法,定位到 63至69行 。首先遍历AnimationFrameCallback回调列表mAnimationCallbacks(该前述我们已经说过,该列表中存储的其实就是我们的属性动画实例),屏判断其中元素是否为null,若为null则跳过,之所以做非空判断,是因为当动画结束后,会将列表中该回调所在位置的元素替换为null而不是将其移除。 第68行的判断 ,是利用动画延迟执行时间与当前时间来检测动画是否应该执行更新,如果是延迟执行动画并且延迟时间已过则上述提及的延迟回调列表会移除该回调。重要的69行来了,回调了ValueAnimator的doAnimationFrame方法来通知动画计算更新。

现在看一下getProvider()方法,定位到第33行,发现是MyFrameCallbackProvider实例,再看看MyFrameCallbackProvider描述吧,"Default provider of timing pulse that uses Choreographer for frame callbacks",翻译过来,大概就是 "利用Choreographer的默认时间脉冲提供者,用于动画帧回调" 。翻译的不好,比较晦涩,其实说的再直白一点,就是: MyFrameCallbackProvider借助于Choreographer提供时间脉冲,当时间脉冲更新时,辅助动画系统完成动画帧更新回调

Choreographer维护一个CallbackQueue回调数组(mCallbackQueues),而CallbackQueue具有CallbackRecord类型成员变量,执行第41行后,会创建将CallbackQueue对象,并将mFrameCallback封装到CallbackQueue对象所持有CallbackRecord成员变量中,然后将CallbackQueue对象赋值给mCallbackQueues。

时间脉冲更新方式及更新回调

a. Api Levle >= 16:根据系统VSYNC机制更新时间脉冲,时间脉冲更新后会触发Choreographer所持有的FrameDisplayEventReceiver类型成员变量mDisplayEventReceiver的onVsync方法,进而触发mDisplayEventReceiver的run方法,run方法内部调用Choreographer的doFrame方法,从而触发其他相关方法使mCallbackQueues数组获取到mFrameCallback并调用其doFrame方法,mFrameCallback的doFrame方法回调AnimationHandler的doAnimationFrame进而完成动画计算更新(请参照代码片段三第60行及代码片段三的第二段解析)至于MyFrameCallbackProvider借助Choreographer完成时间脉冲更新回调的具体细节,请自行参考第41行并继续深度查阅Choreographer代码( 重点关注: doFrame和doCallbacks方法;内部类FrameDisplayEventReceiver及其onVsync和run方法;内部类CallbackRecord),比较复杂。

b. 11 = 16:利用Handler循环更新时间脉,时间脉冲更新后直接触发Choreographer的doFrame方法,后续流程与方法a一致

现在对代码片段三及相关解析归总梳理,调用AnimationHandler的addAnimationFrameCallback后,执行逻辑核心内容如下:

1.创建将CallbackQueue对象,并mFrameCallback封装到CallbackQueue对象所持有CallbackRecord对成员变量中,然后将CallbackQueue对象赋值给mCallbackQueues

2时间脉冲更新,触发AnimationHandler的doAnimationFrame方法进而完成动画计算更新操作(请参照代码片段三第60行及代码片段三的第二段解析)

根据执行流程示意图顺序,现在我们看一下ValueAnimator的doAnimationFrame等方法

代码片段四—— ValueAnimator

/**
     * Processes a frame of the animation, adjusting the start time if needed.
     *
     * @param frameTime The frame time.
     * @return true if the animation has ended.
     * @hide
     */
1    public final void doAnimationFrame(long frameTime) {
2        AnimationHandler handler = AnimationHandler.getInstance();
3        if (mLastFrameTime == 0) {
4            // First frame
5            handler.addOneShotCommitCallback(this);
6            if (mStartDelay > 0) {
7                startAnimation();
8            }
9            if (mSeekFraction  0) {
26                // Offset by the duration that the animation was paused
27                mStartTime += (frameTime - mPauseTime);
28                mStartTimeCommitted = false; // allow start time to be compensated for jank
29            }
30            handler.addOneShotCommitCallback(this);
31        }
        // The frame time might be before the start time during the first frame of
        // an animation.  The "current time" must always be on or after the start
        // time to avoid animating frames at negative time intervals.  In practice, this
        // is very rare and only happens when seeking backwards.
32        final long currentTime = Math.max(frameTime, mStartTime);
33        boolean finished = animateBasedOnTime(currentTime);
34
35        if (finished) {
36            endAnimation();
37        }
38    }

    /**
     * This internal function processes a single animation frame for a given animation. The
     * currentTime parameter is the timing pulse sent by the handler, used to calculate the
     * elapsed duration, and therefore
     * the elapsed fraction, of the animation. The return value indicates whether the animation
     * should be ended (which happens when the elapsed time of the animation exceeds the
     * animation's duration, including the repeatCount).
     *
     * @param currentTime The current time, as tracked by the static timing handler
     * @return true if the animation's duration, including any repetitions due to
     * repeatCount has been exceeded and the animation should be ended.
     */
39    boolean animateBasedOnTime(long currentTime) {
40        boolean done = false;
41        if (mRunning) {
42            final long scaledDuration = getScaledDuration();
43            final float fraction = scaledDuration > 0 ?
44                    (float)(currentTime - mStartTime) / scaledDuration : 1f;
45            final float lastFraction = mOverallFraction;
46            final boolean newIteration = (int) fraction > (int) lastFraction;
47            final boolean lastIterationFinished = (fraction >= mRepeatCount + 1) &&
48                    (mRepeatCount != INFINITE);
49            if (scaledDuration == 0) {
50                // 0 duration animator, ignore the repeat count and skip to the end
51                done = true;
52            } else if (newIteration && !lastIterationFinished) {
53                // Time to repeat
54                if (mListeners != null) {
55                    int numListeners = mListeners.size();
56                    for (int i = 0; i < numListeners; ++i) {
57                        mListeners.get(i).onAnimationRepeat(this);
58                    }
59                }
60            } else if (lastIterationFinished) {
61                done = true;
62            }
63            mOverallFraction = clampFraction(fraction);
64            float currentIterationFraction = getCurrentIterationFraction(mOverallFraction);
65            animateValue(currentIterationFraction);
66        }
67        return done;
68    }

69    void animateValue(float fraction) {
70        fraction = mInterpolator.getInterpolation(fraction);
71        mCurrentFraction = fraction;
72        int numValues = mValues.length;
73        for (int i = 0; i < numValues; ++i) {
74            mValues[i].calculateValue(fraction);
75        }
76        if (mUpdateListeners != null) {
77            int numListeners = mUpdateListeners.size();
78            for (int i = 0; i  0 && mRepeatMode == REVERSE &&
91                (iteration < (mRepeatCount + 1) || mRepeatCount == INFINITE)) {
92            // if we were seeked to some other iteration in a reversing animator,
93            // figure out the correct direction to start playing based on the iteration
94            if (mReversing) {
95                return (iteration % 2) == 0;
96            } else {
97                return (iteration % 2) != 0;
98            }
99        } else {
100            return mReversing;
101        }
102    }

先看一下doAnimationFrame方法,其实比较容易阅读理解,不做过多解释无非是记录动画更新时间、启动时间、计算动画进度等。 只着重留意第6行及第33行 。对代码片段一的分析,我们已经知道,延迟执行的动画,在调用start()方法后除了相关状态的更新外,是不会调用startAnimation()方法来初始化动画的。通过第6行代码的判断,延迟动画做第一帧更新的时候才会调用startAnimation()方法进行初始化。 第33行 调用animateBasedOnTime完成动画计算更新,并判断动画是否执行完毕,若执行完毕会调用endAnimation()方法结束动画,并将自身从AnimationHandler回调列表中移除(其实并不是真正的删减移除,只不过是将回调列表中该动画对应位置的元素设置为null,前述分析已经提及这点)

下面看一下animateBasedOnTime方法。这个方法其实也很容易阅读,并且mOverallFraction变量我们之前也提及过,该值是动画从开始执行到现在所经历的的时间与动画周期的比值,有可能大于1,因为您可能设置了动画重复执行(您可通过第63行继续深入阅读来了解)。因此您可自行阅读来理解是如何判断动画是否结束( 第47行 ),如何确定动画是否到达重复执行临界点及通知我们动画开始重复执行的( 第46、52行 )。直接看 第64、65行 。需要注意的是第64行的currentIterationFraction变量的值,与动画的RepeatMode(往复(REVERSE)及重新开始(RESTART)两种模式)及是否设置重复执行有关,计算方法请参考83至102行。在currentIterationFraction的计算过程中,动画重复执行那部分已经被减到,该值描述了动画当前周期内的执行时间进度( 这里可能有歧义,因为该值与重复模式和重复还行次数有关,第86行的currentFraction 才是真正的当前周期内的时间进度。只不过是根据RepeatMode和已重复执行的次数做了相应调整,所以才说currentIterationFraction描述了时间进度,而没说它就是时间进度 ),利用它来做动画计算。

动画计算重点在animateValue方法,看一下69至82行。该方法接收的参数fraction原本是当前周期内真正的时间进度描述,但最终参与动画值计算的却是一个被"篡改"后的"伪"时间进度,看第70行。真正描述当前周期时间进度的数值经过插值器Interpolator重新计算后参与到动画计算,实际改变了原本的动画变化速率,而动画系统提供了多种内置插值器,您也可以自行定义一个新的插值器来满足您的需求,Interpolator的天生使命就是修改动画变化率以满足我们的需求就像家禽注定是要被人类吃一样~。 第74行 做动画计算, 第76至79行 检测是否设置了更新监听器,若设置则触发回调通知我们动画更新。至于动画计算细节,本文不做解析,涉及的内容也比较多,其中android.animation.Keyframes、android.animation.TypeEvaluator、android.animation.Keyframe比较重要。 其实您或许只需要了解android.animation.TypeEvaluator就好,它的evaluate方法是动画计算的根本,也是我们能改变动画计算的一个入口,另一个入口是插值器

本文的原意在于简单的勾勒出属性动画执行流程,但又恐某些细节不说会导致无法理解,结果导致本文很臃肿却又没有面面俱到,笔者也没有那个能力做到面面俱到、无所纰漏。属性动画是一套比较复杂的系统,很难将其整体性的描述出来,加之本人水平有限,写此文诚惶诚恐,难免有写的不对或不清楚的地方,望读者见谅

简书

责编内容by:简书阅读原文】。感谢您的支持!

您可能感兴趣的

Android O后台持续获取地理位置的简单调研过程... 这几天打算重新捡起之前的智能家居项目,由于近期换了安卓设备,所以要重新开发一款安卓APP。但是其功能很简单---可以在后台稳定地上报地理位置到服务器。 ...
Implementing Downloadable fonts in Android – Kotli... Here is a simple example in which we will download a font from google and set it...
Android: Show html pages when button cli... FULLY UPDATED: I have 100 html files. I know how to initialize the for...
Android中最详细的焦点问题,从概念出发带你一点点分享(1)... 文章最早发布于我的微信公众号 Android_De_Home 中,欢迎大家扫描下面二维码关注微信公众获取更多知识内容。 本文为sydMobile原创文章,可...
Android preferencesActivity and custom header I have a standard PreferencesActivity declared in the following way: public cl...