IM前台开发七之群聊前台实现

在实现群聊前台之前,我们先说一下实现的思路。
首先我们也需要监听群聊数据的改变。所以又少不了数据的监听。之前我们分析过两次数据监听的逻辑,我们这次站在基于底层封装好的情况下,看看实现一个基于监听的数据操作需要做哪些工作。分析一波后,我们就可以根据我们的封装来快速进行基于数据监听的开发了。在此之前我们先看一下创建群组的界面实现,之后我们再分析基础监听的数据操作如何快速实现。

群创建界面实现

因为之前的mvp模式都是用的fragment实现,所以这次这个activity也用mvp实现。但我总觉得有哪些地方可以优化的,这个mvp用的我有点心累…
废话不多说,看看GroupCreateActivity的实现:
在这里插入图片描述
这是其界面布局,主要的就是底部有个RecycleView。用来显示自己的好友。
因为这是个基于Mvp开发模式的Activity。且又有Toolbar设置标题栏,所以我们继承我们之前实现的:PresenterToolbarActivity
在这里插入图片描述
其实现跟我们的PresenterFragment类似,都是传入Presenter的泛型,实现BaseContract.View接口;然后类中持有Presenter,在setPresenter中赋值。这样Activity就能获取到mPresenter。从这个setPresenter中,我们也能看出,我们自己定义的GroupCreatePresenter必须实现BaseContract中Presenter,否则你setPresenter肯定报类型异常的。
分析了GroupCreateActivity的父类,那我们的GroupCreateActivity的实现也就明了了:
在这里插入图片描述
这个GalleryFragment.onSelectedListener,是我们之前实现的画廊借口,是选中头像后的回调。这块逻辑可以参照注册的时候选中个人头像那里的实现。

实际上我们这边需要将这个activity先放一下。毕竟是mvp实现,我们要先实现mvp的内容,顺序实际上是这样的

GroupCreateContract--->GroupCreateActivity--->GroupCreatePresenter

为什么是这个顺序呢?
因为我们的BaseMvpActivity,也就是这里的PresenterToolbarActivity需要传入BaseContract.Presenter的泛型、实现BaseContract.View的接口,所以我们必须定义我们的GroupCreateContract,其中的View继承BaseContract.View,Presenter接口继承BaseContract.Presenter。
所以我们分析一下我们的GroupCrateContract中的Presenter需要什么方法:
首先我们得提供一个create方法把,用来创建群。
还有因为底部我们还有操作选中成员,取消选中的操作,所以还要听一个changeSelect的方法。
因为是一个recycleView,所以少不了要渲染RecyclerView的数据。那为什么不提供一个loadData方法呢?因为我们将数据加载放在了presenter的start方法里了。

同时这里因为有RecycleView的存在,所以我们的View就不继承自BaseContract.View了,而是BaseContract.RecycleView。
那么GroupCreateContract中的实现就如下了:

public interface GroupCreateContract {
    interface Presenter extends BaseContract.Presenter{
        void create(String name,String desc,String picture);
        //更改选中状态
        void changeSelect(ViewModel model,boolean isSelected);
    }

    interface View extends BaseContract.RecyclerView<Presenter,ViewModel>{
          void onCreateSucceed();
    }
    class ViewModel{
       public Author author;
       public  boolean isSelected;
    }
}

然后Activity继承PresenterToolbarActivity,传入presenter泛型和实现view接口即可。看看我们最重要的GroupCreatePresenter:
在这里插入图片描述
先看其继承依赖关系。因为其中有RecycleView的存在,所以我们这里的Presenter不像以往的继承BasePresenter,而是继承自BaseRecyclerPresenter,这个类里就是新增了一些对RecycleView的操作封装,就是全量刷新数据,和差量更新。关于Mvp的封装,我觉有有必要之后单独写一篇分析博客。
言归正传,除了继承BaseRecyclerPresenter之外,可以看到还是先Presenter接口,和一个数据回调的接口,一个是实现,一个是反馈给上层,没毛病,跟之前其他的Presenter实现完全一致。

在我们的start方法里,就是加载数据了:

    @Override
    public void start() {
        super.start();
        Factory.runOnAsync(loader);
    }
    /**
     * 加载RecycleView数据
     */
    private Runnable loader= () -> {
        List<UserSampleModel> sampleModels= UserHelper.getSampleontact();
        List<GroupCreateContract.ViewModel> models=new ArrayList<>();
        for (UserSampleModel sampleModel:sampleModels){
            GroupCreateContract.ViewModel viewModel=new GroupCreateContract.ViewModel();
            viewModel.author=sampleModel;
            models.add(viewModel);
        }
        //刷新界面
        refreshData(models);
    };

这里我们通过异步加载,刷新用户信息。可以看到我们新增了一个类:
UserSampleModel。

@QueryModel(database = AppDatabase.class)
public class UserSampleModel implements Author {
    @Column
    public String id;
    @Column
    public String name;
    @Column
    public String portrait;

    @Override
    public String getId() {
        return id;
    }

    @Override
    public void setId(String id) {
        this.id = id;
    }

    @Override
    public String getName() {
        return name;
    }

    @Override
    public void setName(String name) {
        this.name = name;
    }

    @Override
    public String getPortrait() {
        return portrait;
    }

    @Override
    public void setPortrait(String portrait) {
        this.portrait = portrait;
    }
}

可以看到我们这里用的是DBFlow里的关联查询操作,因为这个用户选择列表只需要用户的极少部分信息:id,name,portrait;所以没必要用拿到用户的所有信息,这个操作真的是6666.避免了数据的滥用。就是这个操作不知道对应到GreenDao中是怎么样的。
这个loader里的关键操作就是这句了:

  List<UserSampleModel> sampleModels= UserHelper.getSampleontact();

    public static List<UserSampleModel> getSampleontact(){
        return     SQLite.select(User_Table.id.withTable().as("id"),
                User_Table.name.withTable().as("name"),
                User_Table.portrait.withTable().as("portrait"))
                .from(User.class)
                .where(User_Table.isFollow.eq(true))
                .and(User_Table.id.notEq(Account.getUserId()))
                .orderBy(User_Table.name, true)
                .queryCustomList(UserSampleModel.class);
    }

这种操作还是66的。本人也加了个todo,看看这种关联查询的操作再其他数据库中如何实现。
最后调用refreshData方法来刷新界面:

    protected void refreshData(final List<ViewModel> dataList) {
        Run.onUiSync(new Action() {
            @Override
            public void call() {
                View view = getView();
                if (view == null) return;
                RecyclerAdapter<ViewModel> adapter = view.getRecyclerAdapter();
                //整体替换数据,这是个全局刷新,replace中有notifyDataChanged
                adapter.replace(dataList);
                //通知上层数据更新,我觉得封装到RecycleAdapter中更好点
                view.onAdapterDataChanged();
            }
        });
    }

通过view.getRecycleAdapter拿到当前界面的adapter。这也是我们Acitivty或者Fragment要实现这个方法的原因。
RecycleView中数据加载的问题解决了,我们看看创建群的操作,也就是我们presenter中的create方法的实现:

    @Override
    public void create(String name, String desc, String picture) {
          GroupCreateContract.View view=getView();
          view.showLoading();
          //判断参数
        if (TextUtils.isEmpty(name)||TextUtils.isEmpty(desc)
                ||TextUtils.isEmpty(picture)||users.size()==0){
            view.showError(R.string.label_group_create_invalid);
            return;
        }
        //上传图片
        Factory.runOnAsync(() -> {
            String url=uploadPicture(picture);
            if (TextUtils.isEmpty(url))
                return;
            //创建群
            GroupCreateModel model=new GroupCreateModel(name,desc,url,users);
            GroupHelper.create(model,GroupCreatePresenter.this);
        });
    }

    /**
     * 群的创建,网络请求
     *
     * @param model
     * @param
     */
    public static void create(GroupCreateModel model, DataSource.Callback<GroupCard> callback) {
        RemoteService service = NetWork.remote();
        service.groupCreate(model)
                .enqueue(new Callback<RspModel<GroupCard>>() {
                    @Override
                    public void onResponse(Call<RspModel<GroupCard>> call, Response<RspModel<GroupCard>> response) {
                        RspModel<GroupCard> rspModel = response.body();
                        if (rspModel.success()) {
                            GroupCard groupCard = rspModel.getResult();
                            //进行保存
                            Factory.getGroupCenter().dispatch(groupCard);
                            callback.onDataLoaded(groupCard);
                        } else {
                            Factory.decodeRspCode(rspModel, callback);
                        }
                    }

                    @Override
                    public void onFailure(Call<RspModel<GroupCard>> call, Throwable t) {
                        callback.onDataNotAvailable(R.string.data_network_error);
                    }
                });
    }

传给服务端GroupCreateModel,然后成功后,通过Factory.getGroupCenter().dispatch(groupCard)。分发操作数据。然后回调给presenter:
在这里插入图片描述
这样数据加载成功会就会反馈到Actiivty中我们定义在GroupCreateContract中的onCreateSucceed()方法,也就是我们群创建成功后的操作,我们在Acitivyt中给个提示,结束掉创建群的界面即可。
这里也要注意,我们所有View的中方法,都要运行在Ui线程中,也就是主线程中,因为都是在这个回调中操作UI界面,因为每个View都需要走主线程,所以后面想想我们能不能提取一下。
presenter一旦实现,我们的activity,创建RecycleView中对应的ViewHolder和Adapter即可。
这里有个坑我这边踩了一下。
就是我们的GroupCreateActivity不是有个Toolbar吗,里面的menu中我们定义了个返回按钮和创建按钮。给Toolbar设置menu,一般来说有三种方法:
1.xml中直接定义
2.后台中toolbar.setMenu
3.后台中覆写onCreateOptionsMenu方法
我们采用的是第三种方法:

    @Override
    public boolean onCreateOptionsMenu(Menu menu) {
        MenuInflater inflater = getMenuInflater();
        inflater.inflate(R.menu.group_create, menu);
        return super.onCreateOptionsMenu(menu);
    }

    @Override
    public boolean onOptionsItemSelected(MenuItem item) {
        if (item.getItemId() == R.id.action_create) {
            onCreateClick();
        }
        return super.onOptionsItemSelected(item);
    }

但是这种方法!必须保证,在我们的onCreate中有这么一行代码:

 setSupportActionBar(mToolbar);

这个定义在我们ToolbarActivity里的initToolbar里,因为个人没有将PresenterToolbarActivity继承自ToolbarAcitivyt而是BaseActivity,导致这行代码没有生效,然后我们的menu就没有了。
我们的GroupCreateActivity暂且分析到这里,看看我们的群展示界面。

GroupFragment

群组的展示界面,除了显示的样式不同之外,其中的逻辑基本上跟我们的ContactFragment是一样的。都是展示一个列表,都要对底层数据库数据进行监听操作。所以我们大致走一遍流程即可:
GroupsContract:

public interface GroupsContract {
    interface  Presenter extends BaseContract.Presenter{
      //调用start方法,所以这里是空实现
    }
    interface  View extends BaseContract.RecyclerView<Presenter, Group>{
        //已经再基类中完成了
    }

}

GroupsPresenter:

public class GroupsPresenter extends BaseSourcePresenter<Group, Group,
        GroupsDataSource, GroupsContract.View> implements GroupsContract.Presenter{
    public GroupsPresenter(GroupsContract.View view) {
        super(new GroupsRepository(), view);
    }

    @Override
    public void start() {
        super.start();
        //加载网络数据,以后可以优化到用户下拉刷新逻辑里面,
        //只有用户下拉刷新才进行网络请求
        GroupHelper.refreshGroups();
    }

    @Override
    public void onDataLoaded(List<Group> groups) {
        final GroupsContract.View view = getView();
        if (view == null) return;
        List<Group> old = view.getRecyclerAdapter().getItems();
        DiffUiDataCallback<Group> callback = new DiffUiDataCallback<>(old, groups);
        DiffUtil.DiffResult result = DiffUtil.calculateDiff(callback);
        //差异更新群组
        refreshData(result, groups);
    }
}

这里的presenter依旧是我们的关键。继承BaseSourcePresenter,这个presenter中在start方法中调用了我们BaseDBRepository的load方法,将数据加载进来。
然后在我们的Presenter的构造方法中传入我的数据仓库:GroupsRepository。

public class GroupsRepository extends BaseDbRepository<Group> implements GroupsDataSource {

    @Override
    public void load(SucceedCallback<List<Group>> callback) {
        super.load(callback);
        SQLite.select()
                .from(Group.class)
                .orderBy(Group_Table.name,true)
                .limit(100)
                .async()
                .queryListResultCallback(this)
                .execute();
    }

    @Override
    protected boolean isRequired(Group group) {
        //一个群的信息,只有两种情况出现在数据库
        //一种是被加入群,第二种是直接建立一个群
        //无论什么情况,拿到的都只是群的信息,没有成员信息
        //需要进行成员信息初始化操作
        if (group.getMemberCount()>0){
            //已经初始化了成员的信息
            group.holder=buildGroupHolder(group);
        }else{
            //待初始化的成员信息
            group.holder=null;
            GroupHelper.refreshGroupMember(group);
        }
        //所有的我的群我都需要关注
        return true;
    }
    //初始化界面显示的成员信息
    private String buildGroupHolder(Group group) {
        //拿到成员信息,拼接名字
        List<MemberUserModel> userModels=group.getLatelyGroupMembers();
        if (userModels==null||userModels.size()==0){
            return null;
        }

        StringBuilder builder=new StringBuilder();

        for (MemberUserModel userModel:userModels){
            builder.append(TextUtils.isEmpty(userModel.alias)?userModel.name:userModel.alias);
            builder.append(",");
        }
        builder.delete(builder.lastIndexOf(","),builder.length());
        return builder.toString();
    }
}

此类继承自BaseDbRepository,这个类中对底层数据库添加了监听器,监听我们的数据库的操作,其中维护了一个dataList,这个dataList就是我们当前数据仓库要操作的数据集合,里面还有一个关键的过滤操作,因为这个dataList拿到的是一整张表的数据,并不是所有的数据都是我们所需要的。

我们所有的数据加载操作都是xxxRepository的load方法中进行的!!
这样BaseSourceRepresenter中的start方法,调用load
方法,我们fragment再调用start方法,就可以将数据加载进去。

然后通过覆写isRequired方法,过滤出我们要加载的数据。然后因为我们的BaseSourcePresenter实现了SucceedCallback<List<Data>> callback接口,在其start方法中调用BaseRepository的load方法时候传入了这个callback。所以load加载完数据,调用callback的时候这个回调会反馈到BaseSourcePresenter中,因为这是个抽象类,所以并没有覆写onDataLoaded方法,所以在我们的GroupsPresenter中必须覆写这个方法,然后这里面传入的List<Group>就是GroupsRepositoy中load完数据后,也就是数据库查询到数据后,会回调给BaseDbRepository里的onListQueryResult方法:

    @Override
    public void onListQueryResult(QueryTransaction transaction, @NonNull List<Data> tResult) {
        if (tResult.size()==0){
            dataList.clear();
            notifyDataChanged();
            return;
        }
        Data[] datas= CollectionUtil.toArray(tResult,dataClass);
        onDataSaved(datas);
    }
    private void notifyDataChanged(){
        SucceedCallback<List<Data>> callback=this.callback;
        if (callback!=null){
            callback.onDataLoaded(dataList);
        }
    }

这样就会调用SucceedCallback,数据就会回调到Presenter里的onDataLoaded方法,所以这个方法里传入的list就是Repository里load并过滤后的数据,在这里进行差量更新:

    @Override
    public void onDataLoaded(List<Group> groups) {
        final GroupsContract.View view = getView();
        if (view == null) return;
        List<Group> old = view.getRecyclerAdapter().getItems();
        DiffUiDataCallback<Group> callback = new DiffUiDataCallback<>(old, groups);
        DiffUtil.DiffResult result = DiffUtil.calculateDiff(callback);
        //差异更新群组
        refreshData(result, groups);
    }

GroupFragment中没有什么难点,就暂且不说了。

接下来我们说一说群组和个人聊天界面的复用性调整。

群组聊天界面

之前的单聊界面我们已经实现了,而群组聊天的界面却还没有实现,只知道我们的群组聊天和单人聊天的界面都有一个共同的父类:ChatFragment。实际上我们的群组聊天和单人聊天区别只在于我们的AppBarLayout中一个是显示一个人的头像,一个是显示群组成员的头像,因为一个群可能有很多的人,所以我们显示的成员头像个数至多四个,多余的人显示一个+1的view,点击即可查看更多。
既然区别只有只有一个地方,那么我们的聊天界面实际上就可以通过一个xml实现,那么我们的layout布局实际上就可以将其提起至ChatFragmnet中:

    @Override   //final,子类不可再覆写
    protected final int getContentLayoutId() {
        return R.layout.fragment_chat_common;
    }

这里加个final,防止我们的ChatUserFragment和ChatGroupFragment重写。

两者的区别在于AppBarLayout那一部分,所以我们完全可以修改复用ChatUserFragment的布局。将:

 <com.google.android.material.appbar.CollapsingToolbarLayout
                android:id="@+id/collapsingToolbarLayout"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:background="@color/trans"
                app:collapsedTitleTextAppearance="@style/TextAppearance.Title"
                app:contentScrim="@color/colorAccent"
                app:expandedTitleGravity="bottom|center_horizontal"
                app:expandedTitleMarginBottom="@dimen/len_16"
                app:expandedTitleTextAppearance="@style/TextAppearance.Title"
                app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
                app:title="@string/app_name"
                app:titleEnabled="true"
                app:toolbarId="@id/toolbar">

                <ImageView
                    android:id="@+id/im_header"
                    android:layout_width="match_parent"
                    android:layout_height="@dimen/len_128"
                    android:scaleType="centerCrop"
                    android:src="@drawable/default_banner_chat"
                    app:layout_collapseMode="parallax"
                    app:layout_collapseParallaxMultiplier="0.7" />

                <androidx.appcompat.widget.Toolbar
                    android:id="@+id/toolbar"
                    android:layout_width="match_parent"
                    android:layout_height="@dimen/actionBarWithStatusBarSize"
                    android:paddingTop="@dimen/statusBarSize"
                    app:layout_collapseMode="pin"
                    tools:targetApi="lollipop" />

                <com.rye.catcher.common.widget.PortraitView
                    android:id="@+id/im_portrait"
                    android:layout_width="@dimen/portraitSize"
                    android:layout_height="@dimen/portraitSize"
                    android:layout_gravity="center"
                    android:src="@drawable/default_portrait"
                    app:civ_border_color="@color/white"
                    app:civ_border_width="1dp"
                    app:layout_collapseMode="parallax"
                    app:layout_collapseParallaxMultiplier="0.3" />

            </com.google.android.material.appbar.CollapsingToolbarLayout>

这里原来是显示一个人头像的地方,我们直接使用ViewStub布局,也就是占位来占个位置。
然后在我们加载ChatUserFragment和ChatGroupFragment的时候,替换成我们的布局:

            <!--占位布局,User和Group不同,AppBarLayout属性别忘了拿到这-->
            <ViewStub
                android:id="@+id/view_stub_header"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                app:layout_scrollFlags="scroll|exitUntilCollapsed|snap" />

这里不要了scrollFlags,如果放在之前的里面会失效的。

这里我们只需要再写两个header布局,将上面的CollapsingToolbarLayout分别换成单聊和群聊的布局即可。
单聊的我们上面贴了,下面我们看看群聊的:

<com.google.android.material.appbar.CollapsingToolbarLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/collapsingToolbarLayout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:background="@color/trans"
    app:collapsedTitleTextAppearance="@style/TextAppearance.Title"
    app:contentScrim="@color/colorAccent"
    app:expandedTitleGravity="bottom|center_horizontal"
    app:expandedTitleMarginBottom="@dimen/len_16"
    app:expandedTitleTextAppearance="@style/TextAppearance.Title"
    app:layout_scrollFlags="scroll|exitUntilCollapsed|snap"
    app:title="@string/app_name"
    app:titleEnabled="true"
    app:toolbarId="@id/toolbar">

<ImageView
    android:id="@+id/im_header"
    android:layout_width="match_parent"
    android:layout_height="@dimen/len_128"
    android:scaleType="centerCrop"
    android:src="@drawable/default_banner_chat"
    app:layout_collapseMode="parallax"
    app:layout_collapseParallaxMultiplier="0.7" />

<androidx.appcompat.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="@dimen/actionBarWithStatusBarSize"
    android:paddingTop="@dimen/statusBarSize"
    app:layout_collapseMode="pin"
    tools:targetApi="lollipop" />

<LinearLayout
    android:id="@+id/lay_members"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:gravity="center_vertical"
    android:orientation="horizontal"
    app:layout_collapseMode="parallax"
    app:layout_collapseParallaxMultiplier="0.3" >
    <TextView
        android:id="@+id/txt_member_more"
        android:layout_width="@dimen/len_32"
        android:layout_height="@dimen/len_32"
        android:background="@drawable/sel_bg_clr_32"
        android:gravity="center"
        android:padding="@dimen/len_2"
        android:textAppearance="@style/TextAppearance.AppCompat.Caption"
        tools:text="+1"/>

</LinearLayout>

</com.google.android.material.appbar.CollapsingToolbarLayout>

这个之前为PortraitView的地方,我们替换成了LinearLayout。这里有个TextVeiw的代表的是群组超过四个人后,不显示更多成员的头像,而是显示还有多少个成员没有显示,点击这个TextView就可以进入一个GroupMemberActivity查看所以的成员了。

那么我们看看这个占位布局是怎么操作的:
在这里插入图片描述
我们在initWidget的super之前find到这个占位布局并inflate。其原因在于,我们的super里,也就是我们的父类的initWidget里,在这里进行的butterknife的bind操作,我们必须要bind之前拿到这个布局,因为我们上面的Toolbar等控件在我们的common的xml布局中是没有的!我们必须再次之前给替换掉,否则会报错找不到资源的。

因为这是头不一样,所以我们这里定义了一个抽象方法:

getHeaderLayoutId()

让两个子布局去实现,拿到不同的头文件,使用占位布局替换即可。

那么我们关心的还是这个怎么将群组个人头像加到我们的布局当中去:

   @Override
    public void onInitGroupMember(List<MemberUserModel> members, long moreCount) {
        if (members == null || members.size() == 0) return;
        // TODO: 2020/2/3 将头像布局抽离出去,Glide也得封装一下 
        LayoutInflater inflater = LayoutInflater.from(getContext());
        for (final MemberUserModel member : members) {
            //添加群成员头像
            ImageView p = (ImageView) inflater.inflate(R.layout.lay_chat_group_portrait, mLayMembers, false);
            mLayMembers.addView(p, 0);
            RequestOptions options = new RequestOptions();
            options.placeholder(R.drawable.default_portrait);
            options.centerCrop();
            options.dontAnimate();
            Glide.with(this)
                    .load(member.portrait)
                    .apply(options)
                    .into(p);
            p.setOnClickListener(v -> PersonalActivity.show(getContext(), member.userId));

        }
        if (moreCount > 0) {
            mMemberMore.setText(String.format("+%s", moreCount));
            mMemberMore.setOnClickListener(new View.OnClickListener() {
                @Override
                public void onClick(View v) {
                    // TODO: 2020/2/3 显示更多成员列表
                }
            });
        } else {
            mMemberMore.setVisibility(View.GONE);
        }
    }

我们将我们的头像布局单独抽离出来,放在了:lay_chat_group_portrait.xml文件中。
然后通过ViewGroup自带的addView方法,将头像添加进去。
可以看到这里我们项目中,也没有封装一个头像加载库。这个我们后期也得完善一下。

还有一点就是我们单聊和群聊,右边的图标选项不同,单聊点进去就是查看个人的信息,群聊是查看一个群的所有群成员。这里我们根据权限来,如果是群的管理员,我们可以有个添加人的功能,普通成员点进去就是只能看到群里有哪些成员。这个功能没什么复杂的,所以就不细说了。

实际上,到这里,我们的项目基本可以说初步完成了,已经实现了重要的单聊和群聊功能了,不过还是有很多点需要完善的,举例说:
1.比如在Session列表页,加一些标志,区分单聊和群聊。
2.Session界面加红点
3.聊天界面消息在输入框下,没有顶上去。
4.个人界面有个关注按钮,没有完成
5.群界面滑动的时候,浮动按钮能够滑到边缘外
6.去除老师提供的三方库,研究其实现,自己完成控件的封装,比如那个圆形头像,我们自己用BitmapShader来实现即可。
7.图片加载库也进行一下封装,不用每次都用Glide加载图片。
8.等等等等…好在自己跟的时候有加过todo,之后将todo挨个解决一下。
下面我们就可以进入扩展阶段了,即发送表情、发送图片、发送语音。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值