FanChat學習筆記(四)——主頁面

主頁面是FanChat的重點,本文還是接着前面繼續總結。如果沒有看過FanChat學習筆記(三)——註冊頁的話,建議先翻看前面的內容。因爲本文打算研究主界面的很多主要功能,爲了細緻研究FanChat,我打算將其運行起來。但是之前也運行過,結果在實例化環信SDK時找不到libsqlite.so文件。
於是我猜測估計是環信SDK是舊版本,於是我將其更新到最新版本。後來我發現一個非常奇怪的事情,官方源碼與我jar包的源碼不一致!!!於是我找到了官網客服,他們也是嚇了一跳但無可奈何!

這裏寫圖片描述

後來我重新建了一個項目,然後重頭配置,我發現根本找不到jniLibs文件夾下jar包內的文件,後來我才知道:
原來AndroidStudio是jar包放在libs文件夾,so文件放在jniLibs文件夾,如果so文件也是放在libs文件夾下的話,那麼需要進行如下配置:

android {
    sourceSets {
        main {
            jniLibs.srcDirs = ['libs']
        }
    }
}

經過大半天的折騰,總算把該項目運行起來了!本來心裏有點沮喪的,但是想想只要每天學習一點以前沒有掌握的東西,那麼也算是一種進步。於是今天又開始學習之前不曾掌握的東西了!
主界面其實Activity裏面沒有什麼東西值得提到的,因爲簡單到都沒有使用MVP的設計模式,這裏也說明了我們在編寫代碼的時候也沒有必要因爲設計而過度設計。但是我覺得這裏面還是有兩個亮點:

  • Fragment的工廠類——FragmentFactory
  • 爲了不重複造輪子選擇開源庫——BottomBar

其實FragmentFactory非常簡單,就是一個單例,然後提供一個方法getFragment(int id)返回相對應的Fragment。並不是非常高深的寫法,但是這樣寫的高明之處在於可以將Fragment分離出來,有利於後期擴展和維護,反正我是這樣認爲的。

package com.itheima.leon.qqdemo.factory;

import com.itheima.leon.qqdemo.R;
import com.itheima.leon.qqdemo.ui.fragment.BaseFragment;
import com.itheima.leon.qqdemo.ui.fragment.ContactFragment;
import com.itheima.leon.qqdemo.ui.fragment.ConversationFragment;
import com.itheima.leon.qqdemo.ui.fragment.DynamicFragment;

/**
 * 創建者:   Leon
 * 創建時間:  2016/10/17 22:05
 * 描述:    Fragment工廠類
 */
public class FragmentFactory {
    public static final String TAG = "FragmentFactory";

    private static FragmentFactory sFragmentFactory;

    private BaseFragment mMessageFragment;
    private BaseFragment mContactFragment;
    private BaseFragment mDynamicFragment;

    public static FragmentFactory getInstance() {
        if (sFragmentFactory == null) {
            synchronized (FragmentFactory.class) {
                if (sFragmentFactory == null) {
                    sFragmentFactory = new FragmentFactory();
                }
            }
        }
        return sFragmentFactory;
    }

    public BaseFragment getFragment(int id) {
        switch (id) {
            case R.id.conversations:
                return getConversationFragment();
            case R.id.contacts:
                return getContactFragment();
            case R.id.dynamic:
                return getDynamicFragment();
        }
        return null;
    }

    private BaseFragment getConversationFragment() {
        if (mMessageFragment == null) {
            mMessageFragment = new ConversationFragment();
        }
        return mMessageFragment;
    }

    private BaseFragment getDynamicFragment() {
        if (mDynamicFragment == null) {
            mDynamicFragment = new DynamicFragment();
        }
        return mDynamicFragment;
    }

    private BaseFragment getContactFragment() {
        if (mContactFragment == null) {
            mContactFragment = new ContactFragment();
        }
        return mContactFragment;
    }




}

BottomBar這部分我心裏看見界面首先想到是RadioGroup,但是如果有封裝好的開源控件的話,我覺得使用開源控件會提高開發速度,雖然弊端是如果後期遇到問題的時候可能處理起來比較費勁。但是這也需要我們在平時維護項目之餘來研究別人的控件,看看牛人是如何構思的,這樣我們可以更接近大牛,至少接近了它的技術。我記得好像郭神說過這樣的話!
作者在這方面的還介紹了一些相關的開源控件:

  • BottomBar
  • AHBottomNavigation
  • BottomNavigation

我簡單的看了看其它的效果,我覺得目前這種效果已經足夠了,所以沒有去研究其它的控件了!這裏簡單說一說如何使用,其實分爲四步就可以實現全部效果:

  1. 添加依賴
  2. 添加圖標及相關文本
  3. 使用控件
  4. 點擊事件處理

添加依賴(控件版本號爲當前最新版本)

 compile 'com.roughike:bottom-bar:2.0.2'

添加圖標及相關文本——首先需要在res/xml文件夾(沒有則自己創建)下新建bottombar_tabs.xml,然後指定相關的ID、文本、icon,如下:

<?xml version="1.0" encoding="utf-8"?>
<tabs>
<tab
    id="@id/conversations"
    icon="@mipmap/ic_conversation_selected_2"
    title="@string/messages"/>
<tab
    id="@id/contacts"
    icon="@mipmap/ic_contact_selected_2"
    title="@string/contacts"/>
<tab
    id="@id/dynamic"
    icon="@mipmap/ic_plugin_selected_2"
    title="@string/dynamic"/>
</tabs>

這裏需要強調的是圖標:

The icons must be fully opaque, solid black color, 24dp and with no padding. 

For example, with Android Asset Studio Generic Icon generator, select "TRIM" and make sure the padding is 0dp. 
圖標必須完全不透明,實心黑色,24dp,沒有填充。 例如,使用Android Asset Studio通用圖標生成器,選擇“TRIM”並確保填充爲0dp。

使用控件,這個就簡單了!

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:id="@+id/activity_main"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:orientation="vertical">



    <com.roughike.bottombar.BottomBar
        android:id="@+id/bottomBar"
        android:layout_width="match_parent"
        android:layout_height="60dp"
        //tab資源文件
        app:bb_tabXmlResource="@xml/bottombar_tabs"
        //選中的顏色
        app:bb_activeTabColor="@color/qq_blue"/>

</LinearLayout>

點擊事件處理,這個直接上代碼:

 BottomBar bottomBar = (BottomBar) findViewById(R.id.bottomBar);
        bottomBar.setOnTabSelectListener(new OnTabSelectListener() {
            @Override
            public void onTabSelected(@IdRes int tabId) {
                if (tabId == R.id.tab_favorites) {
                    // The tab with id R.id.tab_favorites was selected,
                    // change your content accordingly.
                }
            }
        });

我們主界面的邏輯是默認爲選中R.id.conversations,所以FragmentFactory返回的是會話界面,當我們點擊其它tab的時候,FragmentFactory就返回其它相應的Fragment。
所以我們首先進入到個人動態DynamicFragment去看看!

其實這個Fragment和其它Activity極其類似,不過這裏面我覺得知識點相對較少,所以我們只要看看String.format就好。因爲代碼中曾用到這方面的知識:

String.format(getString(R.string.logout), EMClient.getInstance().getCurrentUser());

 <string name="logout">退(%s)出</string>

這裏寫圖片描述

之前曾多次看見通過String.format()來實現對數據的文本格式輸出,但是不知道具體可以輸出哪些內容,後來我看看了看這篇文章才發現原來這裏還有這麼多門道。(format方法雖然相對String拼接和Stringbuilder來講性能差一些,但是並不妨礙我們學習人家的用法,至少我們可以多一種選擇!)

JAVA字符串格式化-String.format()的使用

接下來是聯繫人ContactFragment,先看看效果圖:

這裏寫圖片描述

然後我們看看這是如何實現的?整個界面分爲了三個部分:

  1. 聯繫人RecycleView
  2. 右邊框SlideBar
  3. 顯示字母TextView

聯繫人RecycleView並沒有什麼特別的,特別的是RecycleView的模塊化思想。這部分體現在下面:

  • ContactListItemView
  • ContactListItem

ContactListItemView其實就是聯繫人的UI界面,預覽如圖:

這裏寫圖片描述

ContactListItem其實就是一個ViewHolder,就是對ContactListItemView需要的數據進行了一個封裝,代碼如下:

package com.itheima.leon.qqdemo.model;

import com.itheima.leon.qqdemo.R;

/**
 * 創建者:   Leon
 * 創建時間:  2016/10/18 12:10
 * 描述:    聯繫人實體
 */
public class ContactListItem {
    public static final String TAG = "ContactListItem";
    /**
     * 頭像資源
     */
    public int avatar = R.mipmap.avatar6;
    /**
     * 好友名稱
     */
    public String userName;
    /**
     * 是否顯示首先字母
     */
    public boolean showFirstLetter = true;

    /**
     * 獲取首先字母字符
     * @return
     */
    public char getFirstLetter() {
        return userName.charAt(0);
    }

    /**
     * 獲取首寫字母大寫字符串
     * @return
     */
    public String getFirstLetterString() {
        return String.valueOf(getFirstLetter()).toUpperCase();
    }
}

現在很多即時通信sdk裏面都提供了統一的模塊,不過我覺得還是自己寫的這種模塊可以更好的展示了個性化特點(當然了,demo並沒有什麼特點),主要是這種思維確實比一般寫的這種實體和佈局要好一些!

右邊框SlideBar其實就是一個自定義View,一個有6個方法,重點在下面五個方法:

  1. onSizeChanged(int w, int h, int oldw, int oldh)
  2. onDraw(Canvas canvas)
  3. onTouchEvent(MotionEvent event)
  4. notifySectionChange(MotionEvent event)
  5. getTouchIndex(MotionEvent event)

先看看第一個onSizeChanged方法的具體代碼:

    @Override
    protected void onSizeChanged(int w, int h, int oldw, int oldh) {
        mTextSize = h * 1.0f / SECTIONS.length;//計算分配給每個字符的高度
        Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
        float mTextHeight = fontMetrics.descent - fontMetrics.ascent;//獲取默認繪製字符的高度
        mTextBaseline = mTextSize / 2 + mTextHeight/2 - fontMetrics.descent;//計算字符居中時的baseline

    }

這裏寫圖片描述

假設每個字體高爲50dp,默認ascent爲30,默認descent爲50,則矩形高度實際值應該是mTextSize / 2 + mTextHeight/2 =35,則TextBaseline = -15;這個居中並不是單純的居中,而是以descent爲基準向上移。如果上面的圖片不清楚,那麼這張圖應該可以理解吧?

這裏寫圖片描述

如果還是不能理解,建議將上面的mTextBaseline按照自己理解進行賦值,然後你就會明白爲什麼這樣是對的啦!
把需要的數據找到後,接下來就可以進行繪製了,於是進入onDraw方法看看:

  @Override
    protected void onDraw(Canvas canvas) {
        float x = getWidth() * 1.0f / 2;
        float baseline = mTextBaseline;
        //繪製所有首寫字母
        for(int i = 0; i < SECTIONS.length; i++) {
            canvas.drawText(SECTIONS[i], x, baseline, mPaint);
            //將距中線下移,注意是正方向
            baseline += mTextSize;
        }
    }

現在就把文本繪製出來了,接下來需要處理滑動事件了,於是我們看看這裏是怎麼處理的?

 @Override
    public boolean onTouchEvent(MotionEvent event) {
        switch (event.getAction()) {
            case MotionEvent.ACTION_DOWN:
                setBackgroundResource(R.drawable.bg_slide_bar);
                notifySectionChange(event);
                break;
            case MotionEvent.ACTION_MOVE:
                notifySectionChange(event);
                break;
            case MotionEvent.ACTION_UP:
                setBackgroundColor(Color.TRANSPARENT);
                if (mOnSlideBarChangeListener != null) {
                    mOnSlideBarChangeListener.onSlidingFinish();
                }
                break;
        }
        return true;
    }

這裏其實很簡單,首先是按下的時候將透明背景修改爲半透明,然後在滑動過程中將滑動的MotionEvent事件傳給相關方法,然後在擡起來的時候將半透明修改爲透明,然後通過接口回調,使用相關方法。
接下來看看滑動過程中的notifySectionChange(event)執行了什麼邏輯?

/**
     * 喚醒選擇改變事件
     * @param event
     */
    private void notifySectionChange(MotionEvent event) {
        int index = getTouchIndex(event);
        if (mOnSlideBarChangeListener != null && mCurrentIndex != index) {
            mCurrentIndex = index;
            mOnSlideBarChangeListener.onSectionChange(index, SECTIONS[index]);
        }
    }

這裏獲取了當前位置,以SlideBar的26個字母爲基準,計算當前位置應對應哪個字母,如果位置發生改變再次通過接口回調。所以這裏可以看看getTouchIndex(event)方法的具體邏輯:

/**
     * 獲取滑動事件的位置
     * @param event
     * @return
     */
    private int getTouchIndex(MotionEvent event) {
        int index = (int) (event.getY() / mTextSize);
        if (index < 0) {
            index = 0;
        } else if (index > SECTIONS.length - 1) {
            index = SECTIONS.length - 1;
        }
        return index;
    }

最後我們看看在ContactFragment裏面看看這些個接口回調是幹嘛的?在滑動結束和滑動的過程中分別執行了什麼邏輯?

    private SlideBar.OnSlideBarChangeListener mOnSlideBarChangeListener = new SlideBar.OnSlideBarChangeListener() {
        @Override
        public void onSectionChange(int index, String section) {
            //滑動過程中
            //展示顯示字母的TextView
            mSection.setVisibility(View.VISIBLE);
            //設置需要展示的字母
            mSection.setText(section);
            //RecycleView移動到該字母的位置
            scrollToSection(section);
        }

        @Override
        public void onSlidingFinish() {
            //滑動結束關閉顯示字母的TextView
            mSection.setVisibility(View.GONE);
        }
    };
    /**
     * RecyclerView滾動直到界面出現對應section的聯繫人
     *
     * @param section 首字符
     */
    private void scrollToSection(String section) {
        int sectionPosition = getSectionPosition(section);
        if (sectionPosition != POSITION_NOT_FOUND) {
            mRecyclerView.smoothScrollToPosition(sectionPosition);
        }
    }


    /**
     *
     * @param section 首字符
     * @return 在聯繫人列表中首字符是section的第一個聯繫人在聯繫人列表中的位置
     */
    private int getSectionPosition(String section) {
        List<ContactListItem> contactListItems = mContactListAdapter.getContactListItems();
        for (int i = 0; i < contactListItems.size(); i++) {
            if (section.equals(contactListItems.get(i).getFirstLetterString())) {
                return i;
            }
        }
        return POSITION_NOT_FOUND;
    }

界面的邏輯我們理清楚了,接下來我們看看對於聯繫人是如何保存的?在此之前需要先介紹一個隊伍來說算是一個新知識的數據庫框架——greenDAO。

這個東西之前我也沒有接觸過,對於數據庫框架我之前只接觸過LitePal,二者都是一款開源的Android數據庫框架,均採用了對象關係映射(ORM)的模式。由於對後者我也不是很熟悉,也不好評價二者孰好孰壞。不過我們或許應該換一個思維,兩種框架並不一定非得爭個第一,我們應該選擇項目最適合的框架。比如LitePal支持配置簡單,查詢方便,數據庫可以放置在SD卡等便利,但是greenDAO好像配置更簡單,保存也是一樣的方便。但是不管怎麼樣,用郭霖的話說,不管我們喜歡不喜歡,多一個選擇總不是壞事,對吧?
greenDAO的配置基本上都是gradle配置加實體註釋即可,已經有大神對此進行了傻瓜式的註釋,堪稱中文版官網,可以點擊查看!
下面是實體類和數據庫管理類,直接上代碼:

package com.itheima.leon.qqdemo.database;

import org.greenrobot.greendao.annotation.Entity;
import org.greenrobot.greendao.annotation.Id;
import org.greenrobot.greendao.annotation.Generated;

/**
 * 創建者:   Leon
 * 創建時間:  2016/10/21 17:47
 * 描述:    聯繫人實體(數據庫)
 */
@Entity
public class Contact {
    public static final String TAG = "Contact";
    @Id(autoincrement = true)
    private Long id;

    private String username;

    @Generated(hash = 1642963851)
    public Contact(Long id, String username) {
        this.id = id;
        this.username = username;
    }
//@Generated註解爲greenDAO自動生成,如若誤刪,需要重新build
    @Generated(hash = 672515148)
    public Contact() {
    }

    public Long getId() {
        return this.id;
    }

    public void setId(Long id) {
        this.id = id;
    }

    public String getUsername() {
        return this.username;
    }

    public void setUsername(String username) {
        this.username = username;
    }

}
package com.itheima.leon.qqdemo.database;

import android.content.Context;
import android.database.sqlite.SQLiteDatabase;

import com.itheima.leon.qqdemo.app.Constant;

import java.util.ArrayList;
import java.util.List;

/**
 * 創建者:   Leon
 * 創建時間:  2016/10/21 18:57
 * 描述:    數據庫管理
 */
public class DatabaseManager {
    public static final String TAG = "DatabaseManager";

    private static DatabaseManager sInstance;
    private DaoSession mDaoSession;


    public static DatabaseManager getInstance() {
        if (sInstance == null) {
            synchronized (DatabaseManager.class) {
                if (sInstance == null) {
                    sInstance = new DatabaseManager();
                }
            }
        }
        return sInstance;
    }

    /**
     * 實例化
     * @param context
     */
    public void init(Context context) {
        DaoMaster.DevOpenHelper devOpenHelper = new DaoMaster.DevOpenHelper(context, Constant.Database.DATABASE_NAME, null);
        SQLiteDatabase writableDatabase = devOpenHelper.getWritableDatabase();
        DaoMaster daoMaster = new DaoMaster(writableDatabase);
        mDaoSession = daoMaster.newSession();
    }

    /**
     * 保存聯繫人
     * @param userName
     */
    public void saveContact(String userName) {
        Contact contact = new Contact();
        contact.setUsername(userName);
        mDaoSession.getContactDao().save(contact);
    }

    /**
     * 查詢聯繫人
     * @return
     */
    public List<String> queryAllContacts() {
        List<Contact> list = mDaoSession.getContactDao().queryBuilder().list();
        ArrayList<String> contacts = new ArrayList<String>();
        for (int i = 0; i < list.size(); i++) {
            String contact = list.get(i).getUsername();
            contacts.add(contact);
        }
        return contacts;
    }

    /**
     * 刪除所有聯繫人
     */
    public void deleteAllContacts() {
        ContactDao contactDao = mDaoSession.getContactDao();
        contactDao.deleteAll();

    }
}

對於數據庫的調用在ContactFragment的業務邏輯實現類ContactPresenterImpl,我們看看代碼就明白了!

package com.itheima.leon.qqdemo.presenter.impl;

import com.hyphenate.chat.EMClient;
import com.hyphenate.exceptions.HyphenateException;
import com.itheima.leon.qqdemo.database.DatabaseManager;
import com.itheima.leon.qqdemo.model.ContactListItem;
import com.itheima.leon.qqdemo.presenter.ContactPresenter;
import com.itheima.leon.qqdemo.utils.ThreadUtils;
import com.itheima.leon.qqdemo.view.ContactView;

import java.util.ArrayList;
import java.util.List;

/**
 * 創建者:   Leon
 * 創建時間:  2016/10/18 15:34
 * 描述:    TODO
 */
public class ContactPresenterImpl implements ContactPresenter {
    private static final String TAG = "ContactPresenterImpl";

    private ContactView mContactView;

    private List<ContactListItem> mContactListItems;

    public ContactPresenterImpl(ContactView contactView) {
        mContactView = contactView;
        mContactListItems = new ArrayList<ContactListItem>();
    }

    /**
     * 獲取聯繫人
     * @return
     */
    @Override
    public List<ContactListItem> getContactList() {
        return mContactListItems;
    }

    /**
     * 刷新聯繫人
     */
    @Override
    public void refreshContactList() {
        mContactListItems.clear();
        getContactsFromServer();
    }

    /**
     * 刪除聯繫人
     * @param name
     */
    @Override
    public void deleteFriend(final String name) {
        ThreadUtils.runOnBackgroundThread(new Runnable() {
            @Override
            public void run() {
                try {
                    EMClient.getInstance().contactManager().deleteContact(name);
                    ThreadUtils.runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            mContactView.onDeleteFriendSuccess();
                        }
                    });
                } catch (HyphenateException e) {
                    e.printStackTrace();
                    ThreadUtils.runOnUiThread(new Runnable() {
                        @Override
                        public void run() {
                            mContactView.onDeleteFriendFailed();
                        }
                    });
                }
            }
        });
    }

    /**
     *從服務器獲取聯繫人列表
     */
    @Override
    public void getContactsFromServer() {
        if (mContactListItems.size() > 0) {
            mContactView.onGetContactListSuccess();
            return;
        }
        ThreadUtils.runOnBackgroundThread(new Runnable() {
            @Override
            public void run() {
                try {
                    startGetContactList();
                    notifyGetContactListSuccess();
                } catch (HyphenateException e) {
                    e.printStackTrace();
                    notifyGetContactListFailed();
                }
            }
        });

    }



    /**
     * 獲取聯繫人列表數據
     * @throws HyphenateException
     */
    private void startGetContactList() throws HyphenateException {
        List<String> contacts = EMClient.getInstance().contactManager().getAllContactsFromServer();
        DatabaseManager.getInstance().deleteAllContacts();
        if (!contacts.isEmpty()) {
            for (int i = 0; i < contacts.size(); i++) {
                ContactListItem item = new ContactListItem();
                item.userName = contacts.get(i);
                if (itemInSameGroup(i, item)) {
                    item.showFirstLetter = false;
                }
                mContactListItems.add(item);
                saveContactToDatabase(item.userName);
            }
        }
    }

    private void saveContactToDatabase(String userName) {
        DatabaseManager.getInstance().saveContact(userName);
    }

    /**
     * 當前聯繫人跟上個聯繫人比較,如果首字符相同則返回true
     * @param i 當前聯繫人下標
     * @param item 當前聯繫人數據模型
     * @return true 表示當前聯繫人和上一聯繫人在同一組
     */
    private boolean itemInSameGroup(int i, ContactListItem item) {
        return i > 0 && (item.getFirstLetter() == mContactListItems.get(i - 1).getFirstLetter());
    }

    /**
     * 獲取聯繫人成功
     */
    private void notifyGetContactListSuccess() {
        ThreadUtils.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                mContactView.onGetContactListSuccess();
            }
        });
    }

    /**
     * 獲取聯繫人失敗
     */
    private void notifyGetContactListFailed() {
        ThreadUtils.runOnUiThread(new Runnable() {
            @Override
            public void run() {
                mContactView.onGetContactListFailed();
            }
        });
    }
}

最後一個Fragment是ConversationFragment,先看看效果圖:

這裏寫圖片描述

由於前面已經介紹過模塊化思想,所以在這個界面並沒有什麼亮點。我覺得唯一值得一提的就是會話這邊的排序。先看看代碼:

    @Override
    public void loadAllConversations() {
        ThreadUtils.runOnBackgroundThread(new Runnable() {
            @Override
            public void run() {
                Map<String, EMConversation> conversations = EMClient.getInstance().chatManager().getAllConversations();
                mEMConversations.clear();
                mEMConversations.addAll(conversations.values());
                 //根據最後一條消息的時間進行排序
                Collections.sort(mEMConversations, new Comparator<EMConversation>() {
                    @Override
                    public int compare(EMConversation o1, EMConversation o2) {
                        return (int) (o2.getLastMessage().getMsgTime() - o1.getLastMessage().getMsgTime());
                    }
                });
                ThreadUtils.runOnUiThread(new Runnable() {
                    @Override
                    public void run() {
                        mConversationView.onAllConversationsLoaded();
                    }
                });
            }
        });
    }

我們一般排序都是升序或者降序,其實都是根據一個量化的標準來排序。作者使用後一個數據同前一個數據比較,同樣實現了一般情況下取反的降序,我覺得這個知識點也是一個之前很少接觸的,因此仔細看了看這方面的知識。比如這篇博客:JAVA 利用Comparator實現自定義排序

好了,到這裏本文就結束了。這個週末會好好看看搜索朋友和添加朋友兩個Activity,然後看看還有沒有什麼自己之前沒有接觸過的知識點,遇到的話可以好好學習學習了!

學習的項目地址:

github:https://github.com/Vicent9920/FanChat

發佈了61 篇原創文章 · 獲贊 28 · 訪問量 10萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章