介绍 Android MVVM 模式

对于 Android 的 Data Binding 库我一段时间以来一直非常关注. 我决定使用 Model-View-ViewModel 架构实现来 试验 它, 为了做这个, 我拿出了和@matto1990 一起做的应用 HackerNews Reader , 用 Model-View-ViewModel 架构重新实现了它.

这篇文建引用了一个演示MVVM 方式实现的实例 app . 为了帮助你的理解我建议你看一看这里的示例 APP 的仓库

What is MVVM?

Model-View-ViewModel 是一个实现建构用于抽象 view 的状态和行为, 可以让我们分离 UI 和业务逻辑的开发. 这是通过引入一个 ViewModel 实现的, 其职责是展现一个 model 数据对象并处理应用所有包含在 view 展现的逻辑.

这种方法(MVVM)是由三个核心部分,每个都有它自己的不同而独立的作用:

  • Model - 包含业务和验证逻辑的数据模型
  • View -- 定义结构,布局和View 在屏幕上的显示
  • ViewModel - 充当 View 和 ViewModel 之间的连接, 处理所有视图逻辑.

所以这与我们使用的 MVC 方法有设么不同呢? MVC 的架构如下:

  • View View 位于架构的顶部, Controller 其次, Modle 在最下.
  • Controller 同时感知 ViewModel
  • View 仅感知 Model, 而且在任何时刻都会收到 Model 改变的通知.

在 MVVM 中, 架构是类似的, 但是有一些明确的差别

  • ControllerView Model 替代, 位于 UI 层之下.
  • ViewModel 展现 View 所需的数据和指令对象.
  • View Model 接收 Model 的数据.

You can see here that the two approaches use a similar architecture, with the addition of a View Model and the way that it introduces a different approach to the communication between components. The architecture introduces two-way communication between its components, whereas MVC is only capable of one-way communication.

In a nutshell, MVVM is a progression of the MVC architecture - using an additonal layer of non-visual components on top of the Model (but below the View) to map data closer to the View components in the architecture. We’ll take more of a look at the nature of MVVM over the next few sections.

The Hacker News reader

As previously mentioned, I took an old project of mine and stripped it back for use with this article. The features of this sample application consist of:

  • Retrieval of Posts
  • Viewing a single Post
  • Viewing comments for a Post
  • Viewing a selected authors Posts

This was done in the hope that it would reduce the codebase, hence making it a little easier to follow and understand how the implementation operates. The screens of the app that we’re working with are as shown below:

The main part of the application that I’m going to be looking at is the listing of Posts, shown on the left. The comments screen works in pretty much the same way, with a few slight differences (which we’ll look at later).

Displaying Posts

Each Post instance is displayed in a recycler view within a card view, as shown on the left.

Using MVVM we will be able to abstract the different layers that make up this card, meaning that each MVVM component will only be dealing with its assigned responsibility. Using these different components introduced with MVVM, working together they are able to construct the Post card instance. So how can we break this up?

Model

Quite simply put, the Model consists of the business logic belonging to a Post. This includes different properties such as the id, name, text etc. The code below shows a reduced version of this class:


public class Post {

    public Long id;
    public String by;
    public Long time;
    public ArrayList<Long> kids;
    public String url;
    public Long score;
    public String title;
    public String text;
    @SerializedName("type")
    public PostType postType;

    public enum PostType {
        @SerializedName("story")
        STORY("story"),
        @SerializedName("ask")
        ASK("ask"),
        @SerializedName("job")
        JOB("job");

        private String string;

        PostType(String string) {
            this.string = string;
        }

        public static PostType fromString(String string) {
            if (string != null) {
                for (PostType postType : PostType.values()) {
                    if (string.equalsIgnoreCase(postType.string)) return postType;
                }
            }
            return null;
        }
    }

    public Post() { }

}

The Post Model, stripped back of Parcelable and other methods for readability

Here you can see that all our Post Model contains is it’s properties, no other logic has been placed in this class - that’ll be dealt with by the other components.

View

Our View is responsible for defining the layout, appearance and structure of its components. The View itself will be (ideally) constructed completely of XML, however if any java code is used then it should not consist of any business logic. The View retrieves its data from a View Model through the use of binding. Then at run time, the UI content is set and can be updated when the View Model properties flag any change notification events.

To begin with, we created a custom adapter to use with our RecyclerView. For this, we needed to make a create a BindingHolder to keep a reference to our Binding.

public static class BindingHolder extends RecyclerView.ViewHolder {
    private ItemPostBinding binding;

public BindingHolder(ItemPostBinding binding) {
        super(binding.cardView);
        this.binding = binding;
    }
}

The onBindViewHolder() method is where the actual binding of the ViewModel and View takes place. We create a new ItemPostBinding (generated from our item_post layout) and set the View Model to a new instance of our PostViewModel class.

ItemPostBinding postBinding = holder.binding;
postBinding.setViewModel(new PostViewModel(mContext,
                             mPosts.get(position), mIsUserPosts));

Other than the standard adapter views, that’s pretty much it! The full PostAdapter class is displayed below:


public class PostAdapter extends RecyclerView.Adapter<PostAdapter.BindingHolder> {
    private List<Post> mPosts;
    private Context mContext;
    private boolean mIsUserPosts;

    public PostAdapter(Context context, boolean isUserPosts) {
        mContext = context;
        mIsUserPosts = isUserPosts;
        mPosts = new ArrayList<>();
    }

    @Override
    public BindingHolder onCreateViewHolder(ViewGroup parent, int viewType) {
        ItemPostBinding postBinding = DataBindingUtil.inflate(
                LayoutInflater.from(parent.getContext()),
                R.layout.item_post,
                parent,
                false);
        return new BindingHolder(postBinding);
    }

    @Override
    public void onBindViewHolder(BindingHolder holder, int position) {
        ItemPostBinding postBinding = holder.binding;
        postBinding.setViewModel(new PostViewModel(mContext, mPosts.get(position), mIsUserPosts));
    }

    @Override
    public int getItemCount() {
        return mPosts.size();
    }

    public void setItems(List<Post> posts) {
        mPosts = posts;
        notifyDataSetChanged();
    }

    public void addItem(Post post) {
        mPosts.add(post);
        notifyDataSetChanged();
    }

    public static class BindingHolder extends RecyclerView.ViewHolder {
        private ItemPostBinding binding;

        public BindingHolder(ItemPostBinding binding) {
            super(binding.cardView);
            this.binding = binding;
        }
    }

}

Moving on to our XML layout file, we first begin by wrapping our entire layout in a <layout> tag and declare our ViewModel using the <data> tag:

<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable name="viewModel" type="com.hitherejoe.mvvm_hackernews.viewModel.PostViewModel" /></data>
<!-- Other layout views -->
</layout>

Declaring our View Model is required to allow us to reference it throughout our layout file. I’ve made use of the ViewModel in several places within the item_post layout:

  • androidText - It’s possible to set the content of a text view by referencing the corresponding method in our ViewModel. You can see below the use of @{viewModel.postTitle}, this references the getPostTitle() method in our ViewModel - which returns us the title of the corresponding post instance.
  • onClick - We can also reference click events from our layout file. As shown in the layout file, @{viewModel.onClickPost} is used to reference the onClickPost() method in our ViewModel, which returns an OnClickListener containing the click event.
  • visibility - The ability to open the comments activity for a post depends on whether the post has any comments or not. This is done by checking the size of the comments list and setting the visibility based on the result, which should take place in the ViewModel. Here, we use the getCommentsVisiblity() method which returns the calculated visibility.

<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">

    <data>
        <variable name="viewModel" type="com.hitherejoe.mvvm_hackernews.viewModel.PostViewModel" />
    </data>

    <android.support.v7.widget.CardView
        xmlns:card_view="http://schemas.android.com/apk/res-auto"
        android:id="@+id/card_view"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_marginTop="2dp"
        android:layout_marginBottom="2dp"
        card_view:cardCornerRadius="2dp"
        card_view:cardUseCompatPadding="true">

        <LinearLayout
            android:id="@+id/container_post"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:clickable="true"
            android:orientation="vertical"
            android:onClick="@{viewModel.onClickPost}">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical"
                android:padding="16dp"
                android:background="@drawable/touchable_background_white">

                <TextView
                    android:id="@+id/text_post_title"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    android:layout_marginBottom="8dp"
                    android:text="@{viewModel.postTitle}"
                    android:textColor="@color/black_87pc"
                    android:textSize="@dimen/text_large_title"
                    android:onClick="@{viewModel.onClickPost}"/>

                <RelativeLayout
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content">

                    <TextView
                        android:id="@+id/text_post_points"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_alignParentLeft="true"
                        android:text="@{viewModel.postScore}"
                        android:textSize="@dimen/text_body"
                        android:textColor="@color/hn_orange" />

                    <TextView
                        android:id="@+id/text_post_author"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_toRightOf="@+id/text_post_points"
                        android:text="@{viewModel.postAuthor}"
                        android:textColor="@color/black_87pc"
                        android:textSize="@dimen/text_body"
                        android:bufferType="spannable"
                        android:onClick="@{viewModel.onClickAuthor}"/>

                </RelativeLayout>

            </LinearLayout>

            <View
                android:layout_width="match_parent"
                android:layout_height="1dp"
                android:background="@color/light_grey" />

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:background="@color/white">

                <TextView
                    android:id="@+id/text_view_post"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:padding="16dp"
                    android:background="@drawable/touchable_background_white"
                    android:clickable="true"
                    android:textColor="@color/black"
                    android:textSize="@dimen/text_small_body"
                    android:textStyle="bold"
                    android:text="@string/view_button"
                    android:onClick="@{viewModel.onClickPost}"/>

                <TextView
                    android:id="@+id/text_view_comments"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:padding="16dp"
                    android:background="@drawable/touchable_background_white"
                    android:clickable="true"
                    android:textColor="@color/hn_orange"
                    android:textSize="@dimen/text_small_body"
                    android:text="@string/comments_button"
                    android:onClick="@{viewModel.onClickComments}"
                    android:visibility="@{viewModel.commentsVisibility}"/>

            </LinearLayout>

        </LinearLayout>

    </android.support.v7.widget.CardView>

</layout>

That’s great isn’t it? This allows us to abstract the display logic from our layout file, handing it over to our ViewModel to take care of it for us.

ViewModel

The ViewModel is the component which acts as the link between the View and the Model, giving it the responsibility of all of the logic relating to our View. The ViewModel is responsible for accessing the methods and properties of the Model, which is then made available to the View. Within our ViewModel, this data can be returned as is or formatted to again remove this responsibility from other components.

In our case, the PostViewModel uses the Post object to handle the display of content on the CardView of a Post instance. Within this class (below) you can see a whole bunch of methods, each corresponding to a different property of our Post View.

  • getPostTitle() - This uses the Post instance to return the Post title
  • getPostAuthor() - This method begins by retreiving a String from the app resources and formatting it with the author of the Post instance. Then if our isUserPosts equates to true we underline the text, finally returning our content String
  • getCommentsVisibility() - This method returns the value that should be used for the comment TextViews visibility
  • onClickPost() - This method returns a click event when the corresponding view is pressed

These samples show the different kinds of logic that can currently be handled by our ViewModel. Below shows the complete PostViewModel class and its methods which are referenced from our item_post view.


public class PostViewModel extends BaseObservable {

    private Context context;
    private Post post;
    private Boolean isUserPosts;

    public PostViewModel(Context context, Post post, boolean isUserPosts) {
        this.context = context;
        this.post = post;
        this.isUserPosts = isUserPosts;
    }

    public String getPostScore() {
        return String.valueOf(post.score) + context.getString(R.string.story_points);
    }

    public String getPostTitle() {
        return post.title;
    }

    public Spannable getPostAuthor() {
        String author = context.getString(R.string.text_post_author, post.by);
        SpannableString content = new SpannableString(author);
        int index = author.indexOf(post.by);
        if (!isUserPosts) content.setSpan(new UnderlineSpan(), index, post.by.length() + index, 0);
        return content;
    }

    public int getCommentsVisibility() {
        return  post.postType == Post.PostType.STORY && post.kids == null ? View.GONE : View.VISIBLE;
    }

    public View.OnClickListener onClickPost() {
        return new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                Post.PostType postType = post.postType;
                if (postType == Post.PostType.JOB || postType == Post.PostType.STORY) {
                    launchStoryActivity();
                } else if (postType == Post.PostType.ASK) {
                    launchCommentsActivity();
                }
            }
        };
    }

    public View.OnClickListener onClickAuthor() {
        return new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                context.startActivity(UserActivity.getStartIntent(context, post.by));
            }
        };
    }

    public View.OnClickListener onClickComments() {
        return new View.OnClickListener() {
            @Override
            public void onClick(View v) {
                launchCommentsActivity();
            }
        };
    }

    private void launchStoryActivity() {
        context.startActivity(ViewStoryActivity.getStartIntent(context, post));
    }

    private void launchCommentsActivity() {
        context.startActivity(CommentsActivity.getStartIntent(context, post));
    }
}

Great, huh? As you can see, our PostViewModel takes care of:

  • Providing the Post objects properties to be displayed in our view
  • Carrying out any required formatting on these properties
  • Returning click events to any views using the onClick attribute
  • Handling the visibility of any views based on Post properties

Testing the ViewModel

One of the great things about MVVM is that our View Model is now extremely easy to unit test. For the PostViewModel, a simple test class was created to test that the methods in the ViewModel were implemented correctly.

  • shouldGetPostScore() - Test the getPostScore() method, to ensure that the score for the Post is correctly formatted as a String and returned.
  • shouldGetPostTitle() - Test the getPostTitle() method, to ensure that the correct Post title is returned.
  • shouldGetPostAuthor() - Test the getPostAuthor() method, to ensure that a correctly formatted string using the Post author is returned.
  • shouldGetCommentsVisiblity() - Test that the getCommentsVisibility() method returns the correct visibility for the ‘Comments’ button on the Post card. We pass ArrayLists of different states to ensure that the correct visibility is returned for each case.

@RunWith(RobolectricTestRunner.class)
@Config(constants = BuildConfig.class, sdk = DefaultConfig.EMULATE_SDK, manifest = DefaultConfig.MANIFEST)
public class PostViewModelTest {

    private Context mContext;
    private PostViewModel mPostViewModel;
    private Post mPost;

    @Before
    public void setUp() {
        mContext = RuntimeEnvironment.application;
        mPost = MockModelsUtil.createMockStory();
        mPostViewModel = new PostViewModel(mContext, mPost, false);
    }

    @Test
    public void shouldGetPostScore() throws Exception {
        String postScore = mPost.score + mContext.getResources().getString(R.string.story_points);
        assertEquals(mPostViewModel.getPostScore(), postScore);
    }

    @Test
    public void shouldGetPostTitle() throws Exception {
        assertEquals(mPostViewModel.getPostTitle(), mPost.title);
    }

    @Test
    public void shouldGetPostAuthor() throws Exception {
        String author = mContext.getString(R.string.text_post_author, mPost.by);
        assertEquals(mPostViewModel.getPostAuthor().toString(), author);
    }

    @Test
    public void shouldGetCommentsVisibility() throws Exception {
        // Our mock post is of the type story, so this should return gone
        mPost.kids = null;
        assertEquals(mPostViewModel.getCommentsVisibility(), View.GONE);
        mPost.kids = new ArrayList<>();
        assertEquals(mPostViewModel.getCommentsVisibility(), View.VISIBLE);
        mPost.kids = null;
        mPost.postType = Post.PostType.ASK;
        assertEquals(mPostViewModel.getCommentsVisibility(), View.VISIBLE);
    }
}

And now we know that our ViewModel is working as it should, great!

Comments

The approach used for comments is very similar to that of the Post instances, however there is one difference that I would like to point out.

Two different view models are used regarding the comments, the CommentHeaderViewModel and CommentViewModel. If you look at the CommentAdapter then you’ll notice two different view types, which are:

private static final int VIEW_TYPE_COMMENT = 0;
private static final int VIEW_TYPE_HEADER = 1;

If the Post type is an Ask post, then we show a header section at the top of the screen containing the question which was asked - the comments are displayed as normal below. You’ll notice in the onCreateViewHolder() method we inflate the layout based on the VIEW_TYPE that we are currently dealing with, this simply returns one of our two different layouts.

if (viewType == _VIEW_TYPE_HEADER_) {
    ItemCommentsHeaderBinding commentsHeaderBinding =
    DataBindingUtil._inflate_(
            LayoutInflater._from_(parent.getContext()),
            R.layout._item_comments_header_,
            parent,
            false);
    return new BindingHolder(commentsHeaderBinding);
} else {
    ItemCommentBinding commentBinding =
        DataBindingUtil._inflate_(
            LayoutInflater._from_(parent.getContext()),
            R.layout._item_comment_,
            parent,
            false);
    return new BindingHolder(commentBinding);
}

Then in our onBindViewHolder() method we create the binding depending on the type view that we’re dealing with. This is because we’re using a slightly different View Model for the cases when there is a header section (for our ASK post question text) used.

if (getItemViewType(position) == _VIEW_TYPE_HEADER_) {
    ItemCommentsHeaderBinding commentsHeaderBinding =
                        (ItemCommentsHeaderBinding) holder.binding;
    commentsHeaderBinding.setViewModel(new
                          CommentHeaderViewModel(mContext, mPost));
} else {
    int actualPosition = (postHasText()) ? position - 1 : position;
    ItemCommentBinding commentsBinding =
                               (ItemCommentBinding) holder.binding;
    mComments.get(actualPosition).isTopLevelComment =
                                               actualPosition == 0;
    commentsBinding.setViewModel(new CommentViewModel(
                         mContext, mComments.get(actualPosition)));
}

And that’s pretty much all that is different about it, the comments section just has two different ViewModel types available - the one chosen is dependent on whether the post is an ASK post or not.

To conclude…

The data binding library, if used correctly, has the potential to really change the way in which we develop applications. There are other ways in which we could make use of data binding in our applications, using an MVVM structure is just one of the ways in which we can do so.

For example, we could simply reference our Model in the layout file and access its properties through a variable reference:

<data>
    <variable name="post" type="your.package.name.model.Post"/>
</data>
<TextView
    ...
    android:text="@{post.title}"/>

This is simple to do and could help to remove some basic display logic from adapters and/or classes. Whilst this is nice, a similar approach could result in the following:

<data>
    <import type="android.view.View"/>
</data>
<TextView
    ...
    android:visibility="@{post.hasComments ? View.Visible :
    View.Gone}"/>

Me upon seeing the above.

For me, this is where Data Binding could have a negative effect on its usage. This is moving a Views display logic into the View itself. Not only do I find this messy, but it would also make testing / debugging more difficult by mixing logic and layout code together.

It’s still too early to know if this approach is the correct way of developing an application, but this experiment has given me a chance to look at one of the possibilities for future projects. It’s something I definitely want to play around with more. If you wish to read more about the Data Binding library, you can do so here. Microsoft has also written a short and easy to understand article on MVVM here.

I’d love to hear your thoughts on this experiment, if you’ve got any comments and/or suggestions then feel free to leave a response or drop me a tweet!

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

推荐阅读更多精彩内容

  • **2014真题Directions:Read the following text. Choose the be...
    又是夜半惊坐起阅读 8,571评论 0 23
  • 自我总结: 颜色没上好。下半部分太冷了,都带着绿色调,层次不够丰富。应该再掺杂一点暖色调比如橙色什么的,阴影用紫色...
    喵喵僧阅读 514评论 5 11
  • 你一定要知道的套路 微信号 how13355789267 欢迎加好友交流 暗号:商学院 消费心理学 大家好,又和...
    精英之门阅读 347评论 1 2
  • 我听到了 森林的失眠 田野的睡梦 风的窃窃私语 无由的 想喝杯酒 因为得不到的 都会在酒里映现 你 是最浪漫的枪眼...
    执念馥郁阅读 158评论 0 1
  • 我意识到当我无法说服别人的时候,一个很重要的前提是我无法说服我自己! 我想说不要因为好奇心驱使下去尝试一些本不该拥...
    浪迹_绝尘阅读 164评论 0 0