Android日期显示和选择库实现原理

自己当初编写CalendarSelector库主要是为了解决日期的选择问题,比如说档期(一个人的档期大部分是一段连续的时间)。
后来随着功能的完善,发现还可以很好的满足一些其它的需求,比如选择某几天,或者纯粹的显示某个月。为了满足这些需求,自己进行了几个版本的迭代,在迭代中也解决了几个自己觉得比较棘手的问题。下面会分析自己的实现思路,具体的实现过程和进行的一些优化。

MonthView的绘制

View实现方式

MonthView是对月天数的组合显示,使得以月为整体来展示。自己最初的做法是MonthView为一个原始的View,通过Canvas来绘制每一天,根据这个思路实现了一个版本,但最后被自己放弃。因为如果想要实现一些动画的效果或者想自定义天的显示太麻烦了,只能通过Canvas来绘制,坐标的计算太繁琐了,也很容易出现误差~

ViewGroup实现方式

View实现方式被放弃之后,自己就在思考如何让绘制和增加一些动画能易于实现,并且方便第三方使用。自己最后选择了ViewGroup的方式,月为ViewGroup,而月的每一天都为一个单独的View,通过组合的方式来实现一个月的显示,一个是自己不用管理天的绘制,而由于天的显示被抽象成View,添加动画,自定义自然会很方便。

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    for (int index = 0, count = getChildCount();
         index < count; index++){
        View childView = getChildAt(index);
        int row = index / COL_COUNT;
        int col = index - row * COL_COUNT;
        int l = col * dayWidth;
        int t = row * dayHeight;
        int r = l + dayWidth;
        int b = t + dayHeight;
        // layout day view
        childView.layout(l, t,
                r, b);
    }
}
private void createDayViews() {
    for (int row = 0; row < ROW_COUNT; row++){
        for (int col = 0; col < COL_COUNT; col++){
            DayViewHolder dayViewHolder = dayInflater.inflateDayView(this);
            View dayView = dayViewHolder.getDayView();
            dayView.setLayoutParams(new ViewGroup.LayoutParams(
                    dayWidth,
                    dayHeight));
            addView(dayView);
            dayViewHolders[row][col] = dayViewHolder;
            drawDays(row, col, dayView);
            dayView.setClickable(true);
            final int clickRow = row;
            final int clickCol = col;
            dayView.setOnClickListener(new OnClickListener() {
                @Override
                public void onClick(View v) {
                    measureClickCell(clickRow, clickCol);
                }
            });
        }
    }
}

针对RecyclerView的优化

由于每个月的天数存在不相等的情况,所以当把MonthView嵌入到RecyclerView中时会存在一些小问题。由于RecyclerView回收机制的存在,有可能存在回收使用的MonthView的height跟当前需要显示的月份要求的height不匹配,这个时候就需要requestLayout,重新measure、layout和draw。
但是呢当两者height相等时并不需要走这个流程,因为这个流程还是相当耗时的,为了针对这个进行优化,做了一些判断。

// when use in the recyclerview, each item's height may be different, we should requestLayout again
if(neededRelayout) {
    requestLayout();
    neededRelayout = false;
}

neededRelayout在计算月的天数时来进行判断,如果行相同,那么就不需要requestLayout()。

if(drawMonthDay) {
    if(realRowCount != currentRealRowCount) neededRelayout = true;
    realRowCount = currentRealRowCount;
}

在使用RecyclerView时减少一些对象的创建,对性能的改进还是明显的,可以减少gc的频率,降低内存抖动,有效的减少掉帧的情况出现。

DayViewInflater抽象的实现

当初自己构思DayViewInflater实现时,不得不惊讶于代码有结构的组织带来的效果真是大啊,通过对DayViewInflater抽象的实现,自己之前一直纠结的灵活自定义天的显示和选中、未选择中状态切换动画,变得是那么的简单,一切皆迎刃而解。

DayViewInflater

public abstract class DayViewInflater {

    protected Context mContext;
    protected LayoutInflater mLayoutInflater;

    public DayViewInflater(Context context){
        mContext = context;
        mLayoutInflater = LayoutInflater.from(mContext);
    }

    /**
     * inflate day view
     * @param container MonthView
     * @return day view
     */
    public abstract DayViewHolder inflateDayView(ViewGroup container);

    public Decor inflateHorizontalDecor(ViewGroup container, int row, int totalRow){
        return null;
    }

    public Decor inflateVerticalDecor(ViewGroup container, int col, int totalCol){
        return null;
    }

    protected int dip2px(Context context, float dpValue) {
        final float scale = context.getResources().getDisplayMetrics().density;
        return (int) (dpValue * scale + 0.5f);
    }

    public static class Decor{

        private boolean showDecor = false;
        private View decorView;

        public Decor(View decorView){
            this(decorView, false);
        }

        public Decor(View decorView, boolean showDecor){
            this.decorView = decorView;
            this.showDecor = showDecor;
        }

        public View getDecorView() {
            return decorView;
        }

        public boolean isShowDecor() {
            return showDecor;
        }
    }
}

DayViewHolder

public abstract class DayViewHolder {

    protected Context mContext;
    protected View dayView;

    public DayViewHolder(View dayView){
        this.dayView = dayView;
        mContext = dayView.getContext();
    }

    public View getDayView() {
        return dayView;
    }

    public abstract void setCurrentMonthDayText(FullDay day, boolean isSelected);
    public abstract void setPrevMonthDayText(FullDay day);
    public abstract void setNextMonthDayText(FullDay day);
}

通过DayViewInflater和DayViewHolder的组合,让天的UI自定义非常的方便,而状态切换的动画也更加的方便实现。

AnimDayViewInflater.java (自定义DayViewInflater)

public class AnimDayViewInflater extends DayViewInflater{

    public AnimDayViewInflater(Context context) {
        super(context);
    }

    @Override
    public DayViewHolder inflateDayView(ViewGroup container) {
        View dayView = mLayoutInflater.inflate(R.layout.layout_dayview_custom, container, false);
        return new CustomDayViewHolder(dayView);
    }

    public static class CustomDayViewHolder extends DayViewHolder{

        protected TextView tvDay;
        private int mPrevMonthDayTextColor;
        private int mNextMonthDayTextColor;

        public CustomDayViewHolder(View dayView) {
            super(dayView);
            tvDay = (TextView) dayView.findViewById(com.tubb.calendarselector.library.R.id.tvDay);
            mPrevMonthDayTextColor = ContextCompat.getColor(mContext, com.tubb.calendarselector.library.R.color.c_999999);
            mNextMonthDayTextColor = ContextCompat.getColor(mContext, com.tubb.calendarselector.library.R.color.c_dddddd);
        }

        @Override
        public void setCurrentMonthDayText(FullDay day, boolean isSelected) {
            boolean oldSelected = tvDay.isSelected();
            tvDay.setText(String.valueOf(day.getDay()));
            tvDay.setSelected(isSelected);
            // selected animation
            if(!oldSelected && isSelected){
                AnimatorSet animatorSet = new AnimatorSet();
                animatorSet.setInterpolator(AnimationUtils.loadInterpolator(mContext, android.R.anim.bounce_interpolator));
                animatorSet.play(ObjectAnimator.ofFloat(tvDay, "scaleX", 0.5f, 1.0f))
                        .with(ObjectAnimator.ofFloat(tvDay, "scaleY", 0.5f, 1.0f));
                animatorSet.setDuration(500)
                        .start();
            }
        }

        @Override
        public void setPrevMonthDayText(FullDay day) {
            tvDay.setTextColor(mPrevMonthDayTextColor);
            tvDay.setText(String.valueOf(day.getDay()));
        }

        @Override
        public void setNextMonthDayText(FullDay day) {
            tvDay.setTextColor(mNextMonthDayTextColor);
            tvDay.setText(String.valueOf(day.getDay()));
        }

    }
}

SingleMonthSelector和CalendarSelector分析

其实CalendarSelector库的核心功能由这两个类来实现的,自己的初衷也是为了实现select的功能,下面简要的介绍下实现原理。

SingleMonthSelector

首先对select的功能做了区分,定义了两种模式,分别是选择一段连续的天多个不连续的天

public enum Mode{
    INTERVAL,
    SEGMENT
}

针对这两种模式分别有不同的实现,其实思路是一样的,只不过实现过程中的具体逻辑有一些微小的区别。

INTERVAL模式主要是用来选择多个不连续的日期,通过用List来保存当前选中的日期,实现逻辑比较简单。

protected void intervalSelect(MonthView monthView, FullDay day) {
    if(monthView.getSelectedDays().contains(day)) {
        monthView.removeSelectedDay(day);
        sDays.remove(day);
        if(intervalSelectListener.onInterceptSelect(sDays, day)) return;
    } else {
        if(intervalSelectListener.onInterceptSelect(sDays, day)) return;
        monthView.addSelectedDay(day);
        sDays.add(day);
    }
    intervalSelectListener.onIntervalSelect(sDays);
}

SEGMENT模式主要是用来选择一段连续的日期,通过记录开始选中的日期和结束选中的日期来确定,实现逻辑比较复杂,因为自己想让用户可以取消和重复选择,这样就会有很多的判断在里面,复杂度明显增加了。

private void segmentSelect(MonthView monthView, FullDay ssDay) {
    if(segmentSelectListener.onInterceptSelect(ssDay)) return;

    if(startSelectedRecord.day == null && endSelectedRecord.day == null){ // init status
        startSelectedRecord.day = ssDay;
        monthView.addSelectedDay(ssDay);
    }else if(endSelectedRecord.day == null){ // start day is ok, but end day not

        if(startSelectedRecord.day.getDay() != ssDay.getDay()){
            if(startSelectedRecord.day.getDay() < ssDay.getDay()){
                if(segmentSelectListener.onInterceptSelect(startSelectedRecord.day, ssDay)) return;
                for (int day = startSelectedRecord.day.getDay(); day <= ssDay.getDay(); day++){
                    monthView.addSelectedDay(new FullDay(monthView.getYear(), monthView.getMonth(), day));
                }
                endSelectedRecord.day = ssDay;
            }else if(startSelectedRecord.day.getDay() > ssDay.getDay()){
                if(segmentSelectListener.onInterceptSelect(ssDay, startSelectedRecord.day)) return;
                for (int day = ssDay.getDay(); day <= startSelectedRecord.day.getDay(); day++){
                    monthView.addSelectedDay(new FullDay(monthView.getYear(), monthView.getMonth(), day));
                }
                endSelectedRecord.day = startSelectedRecord.day;
                startSelectedRecord.day = ssDay;
            }
            segmentSelectListener.onSegmentSelect(startSelectedRecord.day, endSelectedRecord.day);
        }else{
            // selected the same day when the end day is not selected
            segmentSelectListener.selectedSameDay(ssDay);
            monthView.clearSelectedDays();
            startSelectedRecord.reset();
            endSelectedRecord.reset();
        }

    }else { // start day and end day is ok
        monthView.clearSelectedDays();
        monthView.addSelectedDay(ssDay);
        startSelectedRecord.day = ssDay;
        endSelectedRecord.reset();
    }
}

CalendarSelector

CalendarSelector的实现相对于SingleMonthSelector的实现要复杂一些,因为要跨MonthView来选择,但是实现的思路跟SingleMonthSelector是一样的,只不过是多了一些判断。
CalendarSelector也有两种模式,这个跟SingleMonthSelector是一样的。

INTERVAL模式跟SingleMonthSelector一样的实现

protected void intervalSelect(MonthView monthView, FullDay day) {
    if(monthView.getSelectedDays().contains(day)) {
        monthView.removeSelectedDay(day);
        sDays.remove(day);
        if(intervalSelectListener.onInterceptSelect(sDays, day)) return;
    } else {
        if(intervalSelectListener.onInterceptSelect(sDays, day)) return;
        monthView.addSelectedDay(day);
        sDays.add(day);
    }
    intervalSelectListener.onIntervalSelect(sDays);
}

SEGMENT模式稍微复杂一些,主要是一些状态的判断,还有MonthView的刷新逻辑。

private void segmentSelect(ViewGroup container, MonthView monthView, FullDay ssDay, int position) {

    if(segmentSelectListener.onInterceptSelect(ssDay)) return;

    if(!startSelectedRecord.isRecord() && !endSelectedRecord.isRecord()){ // init status
        startSelectedRecord.position = position;
        startSelectedRecord.day = ssDay;
        monthView.addSelectedDay(ssDay);
    }else if(startSelectedRecord.isRecord() && !endSelectedRecord.isRecord()){ // start day is ok, but end day not
        if(startSelectedRecord.position < position){ // click later month
            if(segmentSelectListener.onInterceptSelect(startSelectedRecord.day, ssDay)) return;
            endSelectedRecord.position = position;
            endSelectedRecord.day = ssDay;
            segmentMonthSelected(container);
        }else if(startSelectedRecord.position > position){ // click before month
            if(segmentSelectListener.onInterceptSelect(ssDay, startSelectedRecord.day)) return;
            endSelectedRecord.position = startSelectedRecord.position;
            endSelectedRecord.day = startSelectedRecord.day;
            startSelectedRecord.position = position;
            startSelectedRecord.day = ssDay;
            segmentMonthSelected(container);
        }else{ // click the same month
            if(startSelectedRecord.day.getDay() != ssDay.getDay()){
                if(startSelectedRecord.day.getDay() < ssDay.getDay()){
                    if(segmentSelectListener.onInterceptSelect(startSelectedRecord.day, ssDay)) return;
                    for (int day = startSelectedRecord.day.getDay(); day <= ssDay.getDay(); day++){
                        monthView.addSelectedDay(new FullDay(monthView.getYear(), monthView.getMonth(), day));
                    }
                    endSelectedRecord.position = position;
                    endSelectedRecord.day = ssDay;
                }else if(startSelectedRecord.day.getDay() > ssDay.getDay()){
                    if(segmentSelectListener.onInterceptSelect(ssDay, startSelectedRecord.day)) return;
                    for (int day = ssDay.getDay(); day <= startSelectedRecord.day.getDay(); day++){
                        monthView.addSelectedDay(new FullDay(monthView.getYear(), monthView.getMonth(), day));
                    }
                    endSelectedRecord.position = position;
                    endSelectedRecord.day = startSelectedRecord.day;
                    startSelectedRecord.day = ssDay;
                }
                monthView.invalidate();
                segmentSelectListener.onSegmentSelect(startSelectedRecord.day, endSelectedRecord.day);
            }else{
                // selected the same day when the end day is not selected
                segmentSelectListener.selectedSameDay(ssDay);
                monthView.clearSelectedDays();
                startSelectedRecord.reset();
                endSelectedRecord.reset();
            }
        }

    }else if(startSelectedRecord.isRecord() && endSelectedRecord.isRecord()){ // start day and end day is ok
        dataList.get(startSelectedRecord.position).getSelectedDays().clear();
        invalidate(container, startSelectedRecord.position);

        dataList.get(endSelectedRecord.position).getSelectedDays().clear();
        invalidate(container, endSelectedRecord.position);

        int startSelectedPosition = startSelectedRecord.position;
        int endSelectedPosition = endSelectedRecord.position;

        if(endSelectedPosition - startSelectedPosition > 1){
            do {
                startSelectedPosition++;
                dataList.get(startSelectedPosition).getSelectedDays().clear();
                invalidate(container, startSelectedPosition);
            }while (startSelectedPosition < endSelectedPosition);
        }

        startSelectedRecord.position = position;
        startSelectedRecord.day = ssDay;
        dataList.get(startSelectedRecord.position).addSelectedDay(startSelectedRecord.day);
        invalidate(container, position);

        endSelectedRecord.reset();
    }
}

private void invalidate(ViewGroup container, int position){
    if(position >= 0) {
        View childView = container.getChildAt(position);
        if(childView == null){
            if(container instanceof RecyclerView){
                RecyclerView rv = (RecyclerView)container;
                rv.getAdapter().notifyItemChanged(position);
            }else{
                Log.e(TAG, "the container view is not expected ViewGroup");
            }
        }else{
            List<View> unvisited = new ArrayList<>();
            unvisited.add(childView);
            while (!unvisited.isEmpty()) {
                View child = unvisited.remove(0);
                if (!(child instanceof ViewGroup)) {
                    continue;
                }
                ViewGroup group = (ViewGroup) child;
                if(group instanceof MonthView){
                    MonthView monthView = (MonthView) group;
                    monthView.refresh();
                    break;
                }
                final int childCount = group.getChildCount();
                for (int i=0; i<childCount; i++) unvisited.add(group.getChildAt(i));
            }
        }
    }
}

private void segmentMonthSelected(ViewGroup container) {

    SCMonth startMonth = dataList.get(startSelectedRecord.position);
    int startSelectedMonthDayCount = SCDateUtils.getDayCountOfMonth(startMonth.getYear(), startMonth.getMonth());
    for (int day = startSelectedRecord.day.getDay(); day <= startSelectedMonthDayCount; day++){
        startMonth.addSelectedDay(new FullDay(startMonth.getYear(), startMonth.getMonth(), day));
    }
    invalidate(container, startSelectedRecord.position);

    int startSelectedPosition = startSelectedRecord.position;
    int endSelectedPosition = endSelectedRecord.position;

    while (endSelectedPosition - startSelectedPosition > 1){
        startSelectedPosition++;
        SCMonth segmentMonth = dataList.get(startSelectedPosition);
        int segmentSelectedMonthDayCount = SCDateUtils.getDayCountOfMonth(segmentMonth.getYear(), segmentMonth.getMonth());
        for (int day = 1; day <= segmentSelectedMonthDayCount; day++) {
            segmentMonth.addSelectedDay(new FullDay(segmentMonth.getYear(), segmentMonth.getMonth(), day));
        }
        invalidate(container, startSelectedPosition);
    }

    SCMonth endMonth = dataList.get(endSelectedRecord.position);
    for (int day = 1; day <= endSelectedRecord.day.getDay(); day++){
        endMonth.addSelectedDay(new FullDay(endMonth.getYear(), endMonth.getMonth(), day));
    }
    invalidate(container, endSelectedRecord.position);

    segmentSelectListener.onSegmentSelect(startSelectedRecord.day, endSelectedRecord.day);
}

从上面的代码看的出来,在日期的选择过程中把一些拦截的功能交给了使用者,这样方便实现各种特殊的功能,灵活性相对来说比较高。

selector = new CalendarSelector(data, CalendarSelector.Mode.SEGMENT);
selector.setSegmentSelectListener(new SegmentSelectListener() {
    @Override
    public void onSegmentSelect(FullDay startDay, FullDay endDay) {
        Log.d(TAG, "segment select " + startDay.toString() + " : " + endDay.toString());
    }

    @Override
    public boolean onInterceptSelect(FullDay selectingDay) { // one day intercept
        if(SCDateUtils.isToday(selectingDay.getYear(), selectingDay.getMonth(), selectingDay.getDay())){
            Toast.makeText(CalendarSelectorActivity.this, "Today can't be selected", Toast.LENGTH_SHORT).show();
            return true;
        }
        return super.onInterceptSelect(selectingDay);
    }

    @Override
    public boolean onInterceptSelect(FullDay startDay, FullDay endDay) { // segment days intercept
        int differDays = SCDateUtils.countDays(startDay.getYear(), startDay.getMonth(), startDay.getDay(),
                endDay.getYear(), endDay.getMonth(), endDay.getDay());
        Log.d(TAG, "differDays " + differDays);
        if(differDays > 10) {
            Toast.makeText(CalendarSelectorActivity.this, "Selected days can't more than 10", Toast.LENGTH_SHORT).show();
            return true;
        }
        return super.onInterceptSelect(startDay, endDay);
    }

    @Override
    public void selectedSameDay(FullDay sameDay) { // selected the same day
        super.selectedSameDay(sameDay);
    }
})

Selector的自定义

有些朋友可能会说,如果不想使用默认的SingleMonthSelector和CalendarSelector该怎么办,其实不用担心,完全可以自定义一个Selector,实现起来不复杂,因为自己把天的click事件和MonthView的刷新逻辑(某天被选中之后UI的改变)都暴露出来了,根据自己的逻辑使用即可。

总结

上面谈到的几点基本上包含了CalendarSelector库最主要的功能点了,如果还有什么疑问的话,非常欢迎在GITHUB上提issue:)

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

推荐阅读更多精彩内容

  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,056评论 25 707
  • 内容抽屉菜单ListViewWebViewSwitchButton按钮点赞按钮进度条TabLayout图标下拉刷新...
    皇小弟阅读 46,658评论 22 664
  • 最近做了一个Android UI相关开源项目库汇总,里面集合了OpenDigg 上的优质的Android开源项目库...
    OpenDigg阅读 17,123评论 6 223
  • 周日一早,我打开电饭煲,一股粥香便随着热气飘进我的鼻子,淡淡的,带有稻花的清香。这是前一天晚上爱人用剩饭熬的粥。 ...
    聆听万籁阅读 341评论 6 4
  • 你也曾经每天向父母讨要着硬币就是为了“喂饱”它。虽然你不知道为什么要存钱,甚至不是为了花钱而去存钱,你更像是在喂养...
    有宠阅读 514评论 0 0