安卓下实现排序算法动画

前言

最近在学习基础的排序算法,发现仅凭算法的定义公式,即使结合代码在IDE下debug查看数组变化,也依然不是很好的理解,于是就在网上搜索排序算法动画,果然已经有人实现了排序演示,有java实现的,有JS实现,但很想在android手机上看简单演示,最终找到了,ukhanoff/AndroidSortAnimation,一个国际友人,用android实现了基础的冒泡排序法。(左边为他的实现效果,右侧为我的实现效果)

bubble_ukhanoff.gif

pubble.gif

在这之前,我也尝试过使用RecycleView或者自定义View实现类似效果,但依然还是败下阵来,在参考ukhanoff/AndroidSortAnimation后,我增加了其他几种排序算法动画,同时将上边的自定义图形,从球形设置成了长方体,动画效果将和liusaint/sortAnimation
以及在线动画演示各种排序算法过程 - aTool在线工具两种JS实现效果相一致,达到了相对预期的效果,排序算
法分别包括包含冒泡、插入、选择、快速、归并、希尔、堆排序。

merge.gif

接下来,我将分享下android平台下,如何实现排序动画。

备注:文章中仅展示关键代码用来说明思路,全部代码请移步:54wall/SortAnimation

首先大概讲解下大神ukhanoff
,参考将大象被装到冰箱,他是如何实现的冒泡排序法。
他主要用到了三个基础知识:

自定义View

Android属性动画之ValueAnimator

ViewGroup中addView与removeView

接下来分步骤展开说明下

借助自定义View实现可以变色的小球

自定义BubbleView继承AppCompatImageView,新增设置小球处于选中状态,复写onDraw()等方法代码如下:

/**
 * This is custom ImageView which could draw a "Bubble with a number inside".
 */

public class BubbleView extends AppCompatImageView {
    public static final int START_X_POS
 = 25;
    public static final int TEXT_BASELINE_Y = 105;
    public static final int BOTTOM_POS = 120;
    public static final int TOP_POS = 60;
    public static final float TEXT_SIZE = 45f;
    //方法2 直接new 避免avoid object allocation during draw/layout operations (prelocate and reuse instead)
//    Paint paint = new Paint(Paint.LINEAR_TEXT_FLAG);
//    Rect bounds = new Rect();
    Paint paint;
    Rect bounds;
    private String TAG = BubbleView.class.getSimpleName();
    private Integer valueToDraw;
    private boolean isSelected;
    private boolean isOnFinalPlace;

    public BubbleView(Context context) {
        this(context, null);
        init();
    }

    public BubbleView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
        init();
    }

    public BubbleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        init();
    }

    private void init() {
        paint = new Paint(Paint.LINEAR_TEXT_FLAG);
        paint.setAntiAlias(true);
        paint.setTextSize(TEXT_SIZE);
        bounds = new Rect();
    }

    @Override
    protected void onDraw(Canvas canvas) {
//        Log.e(TAG,"onDraw()");
        super.onDraw(canvas);
        if (valueToDraw != null) {
            String text = valueToDraw.toString();
            paint.getTextBounds(text, 0, text.length(), bounds);
            if (isOnFinalPlace) {
                paint.setColor(getResources().getColor(R.color.colorPrimaryDark));
            } else {
                if (isSelected) {
                    paint.setColor(getResources().getColor(R.color.colorIndigo));
                } else {
                    paint.setColor(getResources().getColor(R.color.colorAccent));
                }
            }
            canvas.drawOval(0, TOP_POS, bounds.width() + PADDING, BOTTOM_POS, paint);
            paint.setColor(Color.WHITE);
            canvas.drawText(text, START_X_POS, TEXT_BASELINE_Y, paint);
        }
    }

    /**
     * Draws a number as a bitmap inside of the bubble circle.
     * 在小球中央绘制数字
     * @param numberValueToDraw value which should appears in the center of {@link BubbleView}
     */
    public void setNumber(Integer numberValueToDraw) {
        valueToDraw = numberValueToDraw;
        invalidate();
    }

    /**
     * Background color of bubble will be changed to dark blue.
     *  设置小球处于未选中状态,背景颜色将作出相应改变
     * @param isOnFinalPlace
     */
    public void setBubbleIsOnFinalPlace(boolean isOnFinalPlace) {
        this.isOnFinalPlace = isOnFinalPlace;
        invalidate();
    }

    public boolean isBubbleSelected() {
        return isSelected;
    }

    /**
     * Background color will be changed to blue if true
     * 设置小球处于选中状态,背景颜色将作出相应改变
     *
     * @param isSelected
     */
    public void setBubbleSelected(boolean isSelected) {
        this.isSelected = isSelected;
        invalidate();
    }
}


有了小球之后,我们需要让小球在排序中有选中的状态,并有节奏的闪烁起来,所以属性动画ValueAnimator出场。

借助ValueAnimator让小球闪烁起来

通过属性动画ValueAnimator,他仅作为数值发生器,来控制小球闪烁的频率,相关代码如下:

            //值为0到7,偶数为选中状态,蓝色,基数为未选中状态,粉色,所以,视觉表现为闪烁3次
            blinkAnimation = ValueAnimator.ofInt(0, 7);
            blinkAnimation.setDuration(3000);
            blinkAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    int value = ((Integer) animation.getAnimatedValue()).intValue();
//                    Log.e(TAG,"showNonSwapStep addUpdateListener value:"+value);
                    if (value % 2 == 0) {
                        tempView.setBubbleSelected(false);
                        nextTempView.setBubbleSelected(false);
                    } else {
                        tempView.setBubbleSelected(true);
                        nextTempView.setBubbleSelected(true);
                    }
                }
            });

            blinkAnimation.start();
            blinkAnimation.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    super.onAnimationEnd(animation);
                    tempView.setBubbleSelected(false);
                    nextTempView.setBubbleSelected(false);
                    nextTempView.setBubbleIsOnFinalPlace(isBubbleOnFinalPlace);

                    notifySwapStepAnimationEnd(position);
                }
            });

小球可以闪烁后,需要比较大小的小球可以交换位置,所以ViewGroup的addView和RemoveView出场

addView和removeView实现小位置交换

为了方便后续扩展,大神首先定义了AnimationsCoordinator的接口,主要定义交换位置,不交换位置,结束排序三个方法:

/**
 * Created by ukhanoff on 2/6/17.
 */

public interface AlgorithmStepsInterface {

    /**
     * Visualizes step, when elements should change their places with each other
     *  交换位置
     * @param position             position of the firs element, which should be changed
     * @param isBubbleOnFinalPlace set true, when element after swapping is on the right place and his position is final
     */
    void showSwapStep(int position, boolean isBubbleOnFinalPlace);

    /**
     * Visualizes step, when elements should stay on the same places;
     * 不交换位置
     * @param position             position of the firs element
     * @param isBubbleOnFinalPlace set true, when element on position+1 is on the right place and his position is final
     */
    void showNonSwapStep(int position, boolean isBubbleOnFinalPlace);

    /**
     * Call when last item was sorted. Notifies user that sorting is finished.
     * 结束全部动画,小球将处于最后排序完成后的颜色
     */
    void showFinish();

    /**
     * Cancel all current animations
     */
    void cancelAllVisualisations();
}

AnimationsCoordinator除了实现AlgorithmStepsInterface接口外,在构造函数引入盛放小球的父容器代码如下:

    public AnimationsCoordinator(ViewGroup bubblesContainer) {
        Log.e(TAG, "AnimationsCoordinator");
        this.bubblesContainer = bubblesContainer;
    }

实现showSwapStep方法如下:

    @Override
    public void showSwapStep(final int position, final boolean isBubbleOnFinalPosition) {
        Log.e(TAG, "showSwapStep position:"+position+",isBubbleOnFinalPosition:"+isBubbleOnFinalPosition);
        if (bubblesContainer != null && bubblesContainer.getChildCount() > 0 && bubblesContainer.getChildCount() > position + 1) {
            final BubbleView tempView = (BubbleView) bubblesContainer.getChildAt(position);
            final BubbleView nextTempView = (BubbleView) bubblesContainer.getChildAt(position + 1);
    ···
}

这样便获得了全部的小球,在结合之前的属性动画ValueAnimator,利用ViewGroup的removeView和addView,通过增加子View,移除子View,这样看起来就像是小球实现了移动一样。

            blinkAnimation.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {//
                    super.onAnimationEnd(animation);
                    tempView.setBubbleSelected(false);
                    tempView.setBubbleIsOnFinalPlace(isBubbleOnFinalPosition);
                    nextTempView.setBubbleSelected(false);
                    bubblesContainer.removeView(tempView);
                    bubblesContainer.addView(tempView, position + 1);

                    notifySwapStepAnimationEnd(position);
                }
            });

            blinkAnimation.start();

最后还有小球的每次移动都要记录在animationioList,有了小球移动的历史记录,就可以让小球听话的按照冒泡排序法动起来了。

    private ArrayList<Integer> generateSortScenario(ArrayList<Integer> unsortedValues) {
        Log.e(TAG, "generateSortScenario");
        ArrayList<Integer> values = new ArrayList<>(unsortedValues);
        boolean isLastInLoop;
        for (int i = 0; i < values.size() - 1; i++) {
            for (int j = 0; j < values.size() - i - 1; j++) {
                if (j == values.size() - i - 2) {
                    isLastInLoop = true;
                } else {
                    isLastInLoop = false;
                }
                if (values.get(j) > values.get(j + 1)) {
                    swap(values, j);
                    animationioList.add(new AnimationScenarioItem(true, j, isLastInLoop));
                } else {
                    animationioList.add(new AnimationScenarioItem(false, j, isLastInLoop));
                }
            }
        }
        return values;
    }

全部代码请移步ukhanoff/AndroidSortAnimation

实现android下归并排序算法动画

接下来,我来举例讲讲我fork他的项目后,参考JS实现效果,将小球变为长方体,陆续实现七种常见的算法,我这里仅单独举一个实现归并算法的大概步骤,其余排序算法和全部代码请移步54wall/SortAnimation,相对来说,有元素从原数组取出,重新组成新的一组,相对有些难度:
首先定义归并算法动画控制类MergeStepsInterface,根据归并算法的定义,归并的主要步骤如下:从原数组中选择元素,按照从小到大(或者从大到小)组成新数组,再将新生成的从小到大的数组重新合并到原数组中,所以接口如下:


package pri.weiqiang.sortanimation.animation;

/**
 * Created by weiqiang
 */

public interface MergeStepsInterface {

    /**
     * 从原数组中选择元素组成新数组,顺序为从小到大
     *
     * @param originalPosition 在原数组中的位置
     * @param tempPosition     在新生成的数组中的位置
     * @param isMerge          是否是处于将新生成的数组放置回原数组的那个步骤
     */
    void createTempView(int originalPosition, int tempPosition, boolean isMerge);

    /**
     * 将新生成的从小到大的数组重新合并到原数组中去
     *
     * @param originalPosition 在原数组中的位置
     * @param tempPosition     在新生成的数组中的位置
     * @param isMerge          是否是处于将新生成的数组放置回原数组的那个步骤
     */
    void mergeOriginalView(int originalPosition, int tempPosition, boolean isMerge);

    /**
     * Call when last item was sorted. Notifies user that sorting is finished.
     */
    void showFinish();

    /**
     * Cancel all current animations
     */
    void cancelAllVisualisations();
}

MergeAnimationsCoordinator实现MergeStepsInterface接口,因为归并需要两个ViewGroup来容纳新生成的数组,所以相应的构造函数要做出改变;

    public MergeAnimationsCoordinator(Context context, ViewGroup originalContainer, ViewGroup tempContainer) {
        Log.e(TAG, "MergeAnimationsCoordinator");
        this.context = context;
        this.originalContainer = originalContainer;
        this.tempContainer = tempContainer;
    }

而相应的createTempView和mergeOriginalView方法分别如下:

    /**
     * 从原数组拿取元素,按大小添加下方的新矩形数列中
     *
     * @param originalPosition 在原数组中的位置
     * @param tempPosition     在新生成的数组中的位置
     * @param isMerge          是否是处于将新生成的数组放置回原数组的那个步骤
     */
    @Override
    public void createTempView(final int originalPosition, final int tempPosition, final boolean isMerge) {

        final LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        int marginInPx = Util.dpToPx(context, SortFragment.RECT_MARGIN);
        lp.setMargins(0, 0, marginInPx, 0);

        if (originalContainer != null && originalContainer.getChildCount() > 0 && originalContainer.getChildCount() > tempPosition) {
            final RectView originalView = (RectView) originalContainer.getChildAt(originalPosition);
            //BLINKING
            blinkAnimation = ValueAnimator.ofInt(0, 5);
            blinkAnimation.setDuration(1500);
            blinkAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {

                    int value = (Integer) animation.getAnimatedValue();
                    if (value % 2 == 0) {
                        originalView.setSelected(false);
                    } else {
                        originalView.setSelected(true);
                    }
                }
            });


            blinkAnimation.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    Log.e(TAG, "生成临时矩形!");
                    super.onAnimationEnd(animation);
                    originalView.setSelected(false);
                    originalView.setIsOnFinalPlace(isMerge);
                    originalContainer.removeView(originalView);
                    int tempNumber = originalView.getNumber();
                    originalView.setMinimumHeight(1);
                    originalView.setImageBitmap(createSpaceBitmap(SortFragment.mRectWidth));
                    originalView.setNumber(1);
                    originalContainer.addView(originalView, originalPosition, lp);

                    RectView tempRectView = new RectView(context);
                    tempRectView.setImageBitmap(createCalculatedBitmap(SortFragment.mRectWidth, tempNumber));
                    tempRectView.setNumber(tempNumber);
                    tempContainer.addView(tempRectView, tempPosition, lp);
                    notifySwapStepAnimationEnd(originalPosition);
                }
            });

            blinkAnimation.start();
        }
    }

    /**
     * 将下列排序好的矩形按顺序填回到原矩形序列
     *
     * @param originalPosition 在原数组中的位置
     * @param tempPosition     在新生成的数组中的位置
     * @param isMerge          是否是处于将新生成的数组放置回原数组的那个步骤
     */
    @Override
    public void mergeOriginalView(final int originalPosition, final int tempPosition, final boolean isMerge) {

        final LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT);
        int marginInPx = Util.dpToPx(context, SortFragment.RECT_MARGIN);
        lp.setMargins(0, 0, marginInPx, 0);
        if (originalContainer != null && originalContainer.getChildCount() > 0 && originalContainer.getChildCount() > tempPosition) {
            final RectView originalView = (RectView) originalContainer.getChildAt(originalPosition);
            final RectView tempRectView = (RectView) tempContainer.getChildAt(tempPosition);
            //BLINKING
            blinkAnimation = ValueAnimator.ofInt(0, 6);
            blinkAnimation.setDuration(1200);
            blinkAnimation.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
                @Override
                public void onAnimationUpdate(ValueAnimator animation) {
                    int value = (Integer) animation.getAnimatedValue();
                    if (value % 2 == 0) {
                        originalView.setSelected(false);
                        tempRectView.setSelected(false);
                    } else {
                        originalView.setSelected(true);
                        tempRectView.setSelected(true);
                    }
                }
            });

            blinkAnimation.start();
            blinkAnimation.addListener(new AnimatorListenerAdapter() {
                @Override
                public void onAnimationEnd(Animator animation) {
                    super.onAnimationEnd(animation);
                    originalView.setSelected(false);
                    tempRectView.setSelected(false);
                    tempRectView.setIsOnFinalPlace(isMerge);
                    tempContainer.removeView(tempRectView);
                    int tempNumber = tempRectView.getNumber();

                    tempRectView.setMinimumHeight(1);
                    tempRectView.setImageBitmap(createSpaceBitmap(SortFragment.mRectWidth));
                    tempRectView.setNumber(1);
// 不能设置矩形不可见,还是会报The specified child already has a parent. You must call removeView() on the child's parent first.
//                    tempRectView.setVisibility(View.INVISIBLE);
                    tempContainer.addView(tempRectView, tempPosition, lp);


                    originalContainer.removeView(originalView);
                    RectView originalView = new RectView(context);
                    originalView.setImageBitmap(createCalculatedBitmap(SortFragment.mRectWidth, tempNumber));
                    originalView.setNumber(tempNumber);
                    originalContainer.addView(originalView, originalPosition, lp);
                    notifySwapStepAnimationEnd(originalPosition);
                }
            });
        }
    }

我这里为了保证建立好的ViewGroup中移除的后产生的空白,使用了高度为1的长方体占位来实现,这里特别说明一下。

记录归并算法每次比较元素

这个还是有些难度的,我基本是靠试错,试出来。代码如下:

    // 归并算法 https://www.cnblogs.com/of-fanruice/p/7678801.html
    public static void mergeSort(ArrayList<Integer> unsortedValues, int low, int high, ArrayList<MergeAnimationScenarioItem> mergeAnimationioList) {
        Log.e(TAG, "归并排序! mergeSort");
        int mid = (low + high) / 2;
        if (low < high) {
            mergeSort(unsortedValues, low, mid, mergeAnimationioList);
            mergeSort(unsortedValues, mid + 1, high, mergeAnimationioList);
            // 左右归并
            merge(unsortedValues, low, mid, high, mergeAnimationioList);
        }
    }

    private static void merge(ArrayList<Integer> unsortedValues, int low, int mid, int high, ArrayList<MergeAnimationScenarioItem> mergeAnimationioList) {
        ArrayList<Integer> temp = new ArrayList<>();
        int i = low;
        int j = mid + 1;
        int k = 0;
        // 把较小的数先移到新数组中
        Log.e(TAG, "开始拆分");
        while (i <= mid && j <= high) {
            Log.e(TAG, "子归并merge i:" + i + ":" + unsortedValues.get(i) + ",j:" + j + ":" + unsortedValues.get(j) + ",mid:" + mid);
            if (unsortedValues.get(i) < unsortedValues.get(j)) {
                //选择原始数组中的较小值直接移动到新数组的最末位
                mergeAnimationioList.add(new MergeAnimationScenarioItem(i, k, false));
                temp.add(k++, unsortedValues.get(i++));
            } else {
                mergeAnimationioList.add(new MergeAnimationScenarioItem(j, k, false));
                temp.add(k++, unsortedValues.get(j++));
            }
        }
        // i<=mid是剩余全部中的较小的,把左边剩余的数移入数组
        while (i <= mid) {
            mergeAnimationioList.add(new MergeAnimationScenarioItem(i, k, false));
            temp.add(k++, unsortedValues.get(i++));
        }
        // j <= high是剩余全部中的大的,把右边边剩余的数移入数组,所以在while (i <= mid) 执行
        while (j <= high) {
            mergeAnimationioList.add(new MergeAnimationScenarioItem(j, k, false));
            temp.add(k++, unsortedValues.get(j++));
        }
        // 把新数组中的数覆盖nums数组
        Log.e(TAG, "合并开始");
        for (int x = 0; x < temp.size(); x++) {
            unsortedValues.set(x + low, temp.get(x));
            //返回原始数组
            mergeAnimationioList.add(new MergeAnimationScenarioItem(x + low, x, true));
        }
    }

自定义长方体RectView

略。详情54wall/SortAnimation

Forked & Thanks

感谢浏览,喜欢请赏star。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,547评论 6 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,399评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,428评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,599评论 1 274
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,612评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,577评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,941评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,603评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,852评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,605评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,693评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,375评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,955评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,936评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,172评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,970评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,414评论 2 342

推荐阅读更多精彩内容

  • 【Android 动画】 动画分类补间动画(Tween动画)帧动画(Frame 动画)属性动画(Property ...
    Rtia阅读 6,095评论 1 38
  • 1 初级排序算法 排序算法关注的主要是重新排列数组元素,其中每个元素都有一个主键。排序算法是将所有元素主键按某种方...
    深度沉迷学习阅读 1,389评论 0 1
  • 搞懂基本排序算法 上篇文章写了关于 Java 内部类的基本知识,感兴趣的朋友可以去看一下:搞懂 JAVA 内部类;...
    醒着的码者阅读 1,169评论 3 4
  • 疗愈只是技巧,当我们在专注在与自己同在的领域,疗愈会自然发生,那个转变的过程每个人的感受都可能不一样. 转变: 【...
    Molly_0阅读 947评论 0 0