|
|
|
|
移动端

Android实现社交应用中的评论和回复功能

在Android的日常开发中,评论与回复功能是我们经常遇到的需求之一,其中评论与回复列表的展示一般在功能模块中占比较大。对于需求改动和迭代较频繁的公司来说,如何快速开发一个二级界面来适应我们的功能需求无疑优先级更高一些。

作者:水月沐風来源:简书|2018-07-04 09:59

【新品产上线啦】51CTO播客,随时随地,碎片化学习

在Android的日常开发中,评论与回复功能是我们经常遇到的需求之一,其中评论与回复列表的展示一般在功能模块中占比较大。对于需求改动和迭代较频繁的公司来说,如何快速开发一个二级界面来适应我们的功能需求无疑优先级更高一些。首先我们来看看其他社交类app的评论与回复列表如何展示的:

Android实现社交应用中的评论和回复功能

Android实现社交应用中的评论和回复功能

Twitter不用说了,全球知名社交平台,上亿用户量,他们的评论回复都只展示一级数据(评论数据),其他更多内容(回复内容),是需要页面跳转去查看,知乎也类似。第一张图是我们设计给我找的,他说要按照这个风格来,尽量将评论和回复内容在一个页面展示。好吧,没办法,毕竟我们做前端的,UI要看设计脸色,数据要看后台脸色。看到设计图,我们脑海肯定第一时间联想一下解决方案:用recyclerview?listview?不对,分析一下它的层级发现,评论是一个列表,里面的回复又是一个列表,难道用recyclerview或者listview的嵌套?抱着不确定的态度,立马去网上查一下,果不其然,搜到的实现方式大多都是用嵌套实现的,来公司之前,其中一个项目里的评论回复功能就是用的嵌套listview,虽然处理了滑动冲突问题,但效果不佳,而且时常卡顿,所以,这里我肯定要换个思路。

网上还有说用自定义view实现的,但我发现大多没有处理view的复用,而且开发成本大,暂时不予考虑。那怎么办?无意中看到expandable这个关键词,我突然想到谷歌很早之前出过一个扩展列表的控件 - ExpandableListView,但听说比较老,存在一些问题。算了,试试再说,顺便熟悉一下以前基础控件的用法。

先来看一下最终的效果图吧:

Android实现社交应用中的评论和回复功能

这只是一个简单的效果图,你可以在此基础上来完善它。好了,废话不多说,下面让我们来看看效果具体如何实现的吧。大家应该不难看出来,页面整体采用了CoordinatorLayout来实现详情页的顶部视差效。同时,这里我采用ExpandableListView来实现多级列表,然后再解决它们的嵌套滑动问题。OK,我们先从ExpandableListView开始动手。

ExpandableListView

官方对于ExpandableListView给出这样的解释:

A view that shows items in a vertically scrolling two-level list. This differs from the ListView by allowing two levels: groups which can individually be expanded to show its children. The items come from the ExpandableListAdapter associated with this view.

简单来说,ExpandableListView是一个用于垂直方向滚动的二级列表视图,ExpandableListView与listview不同之处在于,它可以实现二级分组,并通过ExpandableListAdapter来绑定数据和视图。下面我们来一起实现上图的效果。

布局中定义

首先,我们需要在xml的布局文件中声明ExpandableListView:

  1. <ExpandableListView 
  2.     android:id="@+id/detail_page_lv_comment" 
  3.     android:layout_width="match_parent" 
  4.     android:layout_height="match_parent" 
  5.     android:divider="@null" 
  6.     android:layout_marginBottom="64dp" 
  7.     android:listSelector="@android:color/transparent" 
  8.     android:scrollbars="none"/> 

这里需要说明两个问题:

  1. ExpandableListView默认为它的item加上了点击效果,由于item里面还包含了childItem,所以,点击后,整个item里面的内容都会有点击效果。我们可以取消其点击特效,避免其影响用户体验,只需要设置如上代码中的listSelector即可。
  2. ExpandableListView具有默认的分割线,可以通过divider属性将其隐藏。

设置Adapter

正如使用listView那样,我们需要为ExpandableListView设置一个适配器Adapter,为其绑定数据和视图。ExpandableListView的adapter需要继承自ExpandableListAdapter,具体代码如下:

  1. public class CommentExpandAdapter extends BaseExpandableListAdapter { 
  2.     private static final String TAG = "CommentExpandAdapter"
  3.     private List<CommentDetailBean> commentBeanList; 
  4.     private Context context; 
  5.     public CommentExpandAdapter(Context context, List<CommentDetailBean> commentBeanList)               { 
  6.         this.context = context; 
  7.         this.commentBeanList = commentBeanList; 
  8.     } 
  9.     @Override 
  10.     public int getGroupCount() { 
  11.         return commentBeanList.size(); 
  12.     } 
  13.     @Override 
  14.     public int getChildrenCount(int i) { 
  15.         if(commentBeanList.get(i).getReplyList() == null){ 
  16.             return 0; 
  17.         }else { 
  18.             return commentBeanList.get(i).getReplyList().size()>0 ? commentBeanList.get(i).getReplyList().size():0; 
  19.         } 
  20.     } 
  21.     @Override 
  22.     public Object getGroup(int i) { 
  23.         return commentBeanList.get(i); 
  24.     } 
  25.     @Override 
  26.     public Object getChild(int i, int i1) { 
  27.         return commentBeanList.get(i).getReplyList().get(i1); 
  28.     } 
  29.     @Override 
  30.     public long getGroupId(int groupPosition) { 
  31.         return groupPosition; 
  32.     } 
  33.     @Override 
  34.     public long getChildId(int groupPosition, int childPosition) { 
  35.         return getCombinedChildId(groupPosition, childPosition); 
  36.     } 
  37.     @Override 
  38.     public boolean hasStableIds() { 
  39.         return true
  40.     } 
  41.     boolean isLike = false
  42.     @Override 
  43.     public View getGroupView(final int groupPosition, boolean isExpand, View convertView, ViewGroup viewGroup) { 
  44.         final GroupHolder groupHolder; 
  45.         if(convertView == null){ 
  46.             convertView = LayoutInflater.from(context).inflate(R.layout.comment_item_layout, viewGroup, false); 
  47.             groupHolder = new GroupHolder(convertView); 
  48.             convertView.setTag(groupHolder); 
  49.         }else { 
  50.             groupHolder = (GroupHolder) convertView.getTag(); 
  51.         } 
  52.         Glide.with(context).load(R.drawable.user_other) 
  53.                 .diskCacheStrategy(DiskCacheStrategy.RESULT) 
  54.                 .error(R.mipmap.ic_launcher) 
  55.                 .centerCrop() 
  56.                 .into(groupHolder.logo); 
  57.         groupHolder.tv_name.setText(commentBeanList.get(groupPosition).getNickName()); 
  58.         groupHolder.tv_time.setText(commentBeanList.get(groupPosition).getCreateDate()); 
  59.         groupHolder.tv_content.setText(commentBeanList.get(groupPosition).getContent()); 
  60.         groupHolder.iv_like.setOnClickListener(new View.OnClickListener() { 
  61.             @Override 
  62.             public void onClick(View view) { 
  63.                 if(isLike){ 
  64.                     isLike = false
  65.                     groupHolder.iv_like.setColorFilter(Color.parseColor("#aaaaaa")); 
  66.                 }else { 
  67.                     isLike = true
  68.                     groupHolder.iv_like.setColorFilter(Color.parseColor("#FF5C5C")); 
  69.                 } 
  70.             } 
  71.         }); 
  72.         return convertView; 
  73.     } 
  74.     @Override 
  75.     public View getChildView(final int groupPosition, int childPosition, boolean b, View convertView, ViewGroup viewGroup) { 
  76.         final ChildHolder childHolder; 
  77.         if(convertView == null){ 
  78.             convertView = LayoutInflater.from(context).inflate(R.layout.comment_reply_item_layout,viewGroup, false); 
  79.             childHolder = new ChildHolder(convertView); 
  80.             convertView.setTag(childHolder); 
  81.         } 
  82.         else { 
  83.             childHolder = (ChildHolder) convertView.getTag(); 
  84.         } 
  85.         String replyUser = commentBeanList.get(groupPosition).getReplyList().get(childPosition).getNickName(); 
  86.         if(!TextUtils.isEmpty(replyUser)){ 
  87.             childHolder.tv_name.setText(replyUser + ":"); 
  88.         } 
  89.         childHolder.tv_content.setText(commentBeanList.get(groupPosition).getReplyList().get(childPosition).getContent()); 
  90.         return convertView; 
  91.     } 
  92.     @Override 
  93.     public boolean isChildSelectable(int i, int i1) { 
  94.         return true
  95.     } 
  96.     private class GroupHolder{ 
  97.         private CircleImageView logo; 
  98.         private TextView tv_name, tv_content, tv_time; 
  99.         private ImageView iv_like; 
  100.         public GroupHolder(View view) { 
  101.             logo =  view.findViewById(R.id.comment_item_logo); 
  102.             tv_content = view.findViewById(R.id.comment_item_content); 
  103.             tv_name = view.findViewById(R.id.comment_item_userName); 
  104.             tv_time = view.findViewById(R.id.comment_item_time); 
  105.             iv_like = view.findViewById(R.id.comment_item_like); 
  106.         } 
  107.     } 
  108.     private class ChildHolder{ 
  109.         private TextView tv_name, tv_content; 
  110.         public ChildHolder(View view) { 
  111.             tv_name = (TextView) view.findViewById(R.id.reply_item_user); 
  112.             tv_content = (TextView) view.findViewById(R.id.reply_item_content); 
  113.         } 
  114.     } 

一般情况下,我们自定义自己的ExpandableListAdapter后,需要实现以下几个方法:

  • 构造方法,这个应该无需多说了,一般用来初始化数据等操作。
  • getGroupCount,返回group分组的数量,在当前需求中指代评论的数量。
  • getChildrenCount,返回所在group中child的数量,这里指代当前评论对应的回复数目。
  • getGroup,返回group的实际数据,这里指的是当前评论数据。
  • getChild,返回group中某个child的实际数据,这里指的是当前评论的某个回复数据。
  • getGroupId,返回分组的id,一般将当前group的位置传给它。
  • getChildId,返回分组中某个child的id,一般也将child当前位置传给它,不过为了避免重复,可以使用getCombinedChildId(groupPosition, childPosition);来获取id并返回。
  • hasStableIds,表示分组和子选项是否持有稳定的id,这里返回true即可。
  • isChildSelectable,表示分组中的child是否可以选中,这里返回true。
  • getGroupView,即返回group的视图,一般在这里进行一些数据和视图绑定的工作,一般为了复用和高效,可以自定义ViewHolder,用法与listview一样,这里就不多说了。
  • getChildView,返回分组中child子项的视图,比较容易理解,第一个参数是当前group所在的位置,第二个参数是当前child所在位置。

这里的数据是我自己做的模拟数据,不过应该算是较为通用的格式了,大体格式如下:

Android实现社交应用中的评论和回复功能

一般情况下,我们后台会通过接口返回给我们一部分数据,如果想要查看更多评论,需要跳转到“更多页面”去查看,这里为了方便,我们只考虑加载部分数据。

Activity中使用

接下来,我们就需要在activity中显示评论和回复的二级列表了:

  1. private ExpandableListView expandableListView; 
  2. private CommentExpandAdapter adapter; 
  3. private CommentBean commentBean; 
  4. private List<CommentDetailBean> commentsList; 
  5. ... 
  6. @Override 
  7.     protected void onCreate(Bundle savedInstanceState) { 
  8.         super.onCreate(savedInstanceState); 
  9.         setContentView(R.layout.activity_main); 
  10.         initView(); 
  11.     } 
  12.     private void initView() { 
  13.         expandableListView = findViewById(R.id.detail_page_lv_comment); 
  14.         initExpandableListView(commentsList); 
  15.     } 
  16.     /** 
  17.      * 初始化评论和回复列表 
  18.      */ 
  19.     private void initExpandableListView(final List<CommentDetailBean> commentList){ 
  20.         expandableListView.setGroupIndicator(null); 
  21.         //默认展开所有回复 
  22.         adapter = new CommentExpandAdapter(this, commentList); 
  23.         expandableListView.setAdapter(adapter); 
  24.         for(int i = 0; i<commentList.size(); i++){ 
  25.             expandableListView.expandGroup(i); 
  26.         } 
  27.         expandableListView.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() { 
  28.             @Override 
  29.             public boolean onGroupClick(ExpandableListView expandableListView, View viewint groupPosition, long l) { 
  30.                 boolean isExpanded = expandableListView.isGroupExpanded(groupPosition); 
  31.                 Log.e(TAG, "onGroupClick: 当前的评论id>>>"+commentList.get(groupPosition).getId()); 
  32. //                if(isExpanded){ 
  33. //                    expandableListView.collapseGroup(groupPosition); 
  34. //                }else { 
  35. //                    expandableListView.expandGroup(groupPosition, true); 
  36. //                } 
  37.                 return true
  38.             } 
  39.         }); 
  40.         expandableListView.setOnChildClickListener(new ExpandableListView.OnChildClickListener() { 
  41.             @Override 
  42.             public boolean onChildClick(ExpandableListView expandableListView, View viewint groupPosition, int childPosition, long l) { 
  43.                 Toast.makeText(MainActivity.this,"点击了回复",Toast.LENGTH_SHORT).show(); 
  44.                 return false
  45.             } 
  46.         }); 
  47.         expandableListView.setOnGroupExpandListener(new ExpandableListView.OnGroupExpandListener() { 
  48.             @Override 
  49.             public void onGroupExpand(int groupPosition) { 
  50.                 //toast("展开第"+groupPosition+"个分组"); 
  51.             } 
  52.         }); 
  53.     } 
  54.     /** 
  55.      * func:生成测试数据 
  56.      * @return 评论数据 
  57.      */ 
  58.     private List<CommentDetailBean> generateTestData(){ 
  59.         Gson gson = new Gson(); 
  60.         commentBean = gson.fromJson(testJson, CommentBean.class); 
  61.         List<CommentDetailBean> commentList = commentBean.getData().getList(); 
  62.         return commentList; 
  63.     } 

就以上代码作一下简单说明:

ExpandableListView在默认情况下会为我们自带分组的icon(),当前需求下,我们根本不需要展示,可以通过expandableListView.setGroupIndicator(null)来隐藏。

一般情况下,我们可能需要默认展开所有的分组,我就可以通过循环来调用expandableListView.expandGroup(i);方法。

ExpandableListView为我们提供了group和child的点击事件,分别通过setOnGroupClickListener和setOnChildClickListener来设置。值得注意的是,group的点击事件里如果我们返回的是false,那么我们点击group就会自动展开,但我这里碰到一个问题,当我返回false时,第一条评论数据会多出一条。通过百度查找方法,虽然很多类似问题,但终究没有解决,最后我返回了ture,并通过以下代码手动展开和收缩就可以了:

  1. if(isExpanded){ 
  2.     expandableListView.collapseGroup(groupPosition); 
  3. }else { 
  4.     expandableListView.expandGroup(groupPosition, true); 

此外,我们还可以通过setOnGroupExpandListener和setOnGroupCollapseListener来监听ExpandableListView的分组展开和收缩的状态。

评论和回复功能

为了模拟整个评论和回复功能,我们还需要手动插入收据并刷新数据列表。这里我就简单做一下模拟,请忽略一些UI上的细节。

插入评论数据

插入评论数据比较简单,只需要在list中插入一条数据并刷新即可:

  1. String commentContent = commentText.getText().toString().trim(); 
  2. if(!TextUtils.isEmpty(commentContent)){ 
  3.     //commentOnWork(commentContent); 
  4.     dialog.dismiss(); 
  5.     CommentDetailBean detailBean = new CommentDetailBean("小明", commentContent,"刚刚"); 
  6.     adapter.addTheCommentData(detailBean); 
  7.     Toast.makeText(MainActivity.this,"评论成功",Toast.LENGTH_SHORT).show(); 
  8. }else { 
  9.     Toast.makeText(MainActivity.this,"评论内容不能为空",Toast.LENGTH_SHORT).show(); 

adapter中的addTheCommentData方法如下:

  1. public void addTheCommentData(CommentDetailBean commentDetailBean){ 
  2.     if(commentDetailBean!=null){ 
  3.         commentBeanList.add(commentDetailBean); 
  4.         notifyDataSetChanged(); 
  5.     }else { 
  6.         throw new IllegalArgumentException("评论数据为空!"); 
  7.     } 

代码比较容易理解,就不多做说明了。

插入回复数据

首先,我们需要实现点击某一条评论,然后@ta,那么我们需要在group的点击事件里弹起回复框:

  1. expandableListView.setOnGroupClickListener(new ExpandableListView.OnGroupClickListener() { 
  2.         @Override 
  3.         public boolean onGroupClick( 
  4.             ExpandableListView expandableListView, View viewint groupPosition, long l) { 
  5.             showReplyDialog(groupPosition); 
  6.             return true
  7.         } 
  8.     }); 
  9. ...... 
  10. /** 
  11.  * func:弹出回复框 
  12.  */ 
  13. private void showReplyDialog(final int position){ 
  14.     dialog = new BottomSheetDialog(this); 
  15.     View commentView = LayoutInflater.from(this).inflate(R.layout.comment_dialog_layout,null); 
  16.     final EditText commentText = (EditText) commentView.findViewById(R.id.dialog_comment_et); 
  17.     final Button bt_comment = (Button) commentView.findViewById(R.id.dialog_comment_bt); 
  18.     commentText.setHint("回复 " + commentsList.get(position).getNickName() + " 的评论:"); 
  19.     dialog.setContentView(commentView); 
  20.     bt_comment.setOnClickListener(new View.OnClickListener() { 
  21.         @Override 
  22.         public void onClick(View view) { 
  23.             String replyContent = commentText.getText().toString().trim(); 
  24.             if(!TextUtils.isEmpty(replyContent)){ 
  25.                 dialog.dismiss(); 
  26.                 ReplyDetailBean detailBean = new ReplyDetailBean("小红",replyContent); 
  27.                 adapter.addTheReplyData(detailBean, position); 
  28.                 Toast.makeText(MainActivity.this,"回复成功",Toast.LENGTH_SHORT).show(); 
  29.             }else { 
  30.                 Toast.makeText(MainActivity.this,"回复内容不能为空",Toast.LENGTH_SHORT).show(); 
  31.             } 
  32.         } 
  33.     }); 
  34.     commentText.addTextChangedListener(new TextWatcher() { 
  35.         @Override 
  36.         public void beforeTextChanged(CharSequence charSequence, int i, int i1, int i2) { 
  37.         } 
  38.         @Override 
  39.         public void onTextChanged(CharSequence charSequence, int i, int i1, int i2) { 
  40.             if(!TextUtils.isEmpty(charSequence) && charSequence.length()>2){ 
  41.                 bt_comment.setBackgroundColor(Color.parseColor("#FFB568")); 
  42.             }else { 
  43.                 bt_comment.setBackgroundColor(Color.parseColor("#D8D8D8")); 
  44.             } 
  45.         } 
  46.         @Override 
  47.         public void afterTextChanged(Editable editable) { 
  48.         } 
  49.     }); 
  50.     dialog.show(); 

插入回复的数据与上面插入评论类似,这里贴一下adapter中的代码:

  1. /** 
  2.  * by moos on 2018/04/20 
  3.  * func:回复成功后插入一条数据 
  4.  * @param replyDetailBean 新的回复数据 
  5.  */ 
  6. public void addTheReplyData(ReplyDetailBean replyDetailBean, int groupPosition){ 
  7.     if(replyDetailBean!=null){ 
  8.         Log.e(TAG, "addTheReplyData: >>>>该刷新回复列表了:"+replyDetailBean.toString() ); 
  9.         if(commentBeanList.get(groupPosition).getReplyList() != null ){ 
  10.             commentBeanList.get(groupPosition).getReplyList().add(replyDetailBean); 
  11.         }else { 
  12.             List<ReplyDetailBean> replyList = new ArrayList<>(); 
  13.             replyList.add(replyDetailBean); 
  14.             commentBeanList.get(groupPosition).setReplyList(replyList); 
  15.         } 
  16.         notifyDataSetChanged(); 
  17.     }else { 
  18.         throw new IllegalArgumentException("回复数据为空!"); 
  19.     } 

需要注意一点,由于不一定所有的评论都有回复数据,所以在插入数据前我们要判断ReplyList是否为空,如果不为空,直接获取当前评论的回复列表,并插入数据;如果为空,需要new一个ReplyList,插入数据后还要为评论set一下ReplyList。

4、解决CoordinatorLayout与ExpandableListView嵌套问题

如果你不需要使用CoordinatorLayout或者NestedScrollView,可以跳过本小节。一般情况下,我们产品为了更好的用户体验,还需要我们加上类似的顶部视差效果或者下拉刷新等,这就要我们处理一些常见的嵌套滑动问题了。

由于CoordinatorLayout实现NestedScrollingParent接口,RecycleView实现了NestedScrollingChild接口,所以就可以在NestedScrollingChildHelper的帮助下实现嵌套滑动,那么我们也可以通过自定义的ExpandableListView实现NestedScrollingChild接口来达到同样的效果:

  1. /** 
  2.  * Desc: 自定义ExpandableListView,解决与CoordinatorLayout滑动冲突问题 
  3.  */ 
  4. public class CommentExpandableListView extends ExpandableListView implements NestedScrollingChild{ 
  5.     private NestedScrollingChildHelper mScrollingChildHelper; 
  6.     public CommentExpandableListView(Context context, AttributeSet attrs) { 
  7.         super(context, attrs); 
  8.         mScrollingChildHelper = new NestedScrollingChildHelper(this); 
  9.         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { 
  10.             setNestedScrollingEnabled(true); 
  11.         } 
  12.     } 
  13.     @Override 
  14.     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { 
  15.         int expandSpec = MeasureSpec.makeMeasureSpec(Integer.MAX_VALUE >> 2, MeasureSpec.AT_MOST); 
  16.         super.onMeasure(widthMeasureSpec, expandSpec); 
  17.     } 
  18.     @Override 
  19.     public void setNestedScrollingEnabled(boolean enabled) { 
  20.         mScrollingChildHelper.setNestedScrollingEnabled(enabled); 
  21.     } 
  22.     @Override 
  23.     public boolean isNestedScrollingEnabled() { 
  24.         return mScrollingChildHelper.isNestedScrollingEnabled(); 
  25.     } 
  26.     @Override 
  27.     public boolean startNestedScroll(int axes) { 
  28.         return mScrollingChildHelper.startNestedScroll(axes); 
  29.     } 
  30.     @Override 
  31.     public void stopNestedScroll() { 
  32.         mScrollingChildHelper.stopNestedScroll(); 
  33.     } 
  34.     @Override 
  35.     public boolean hasNestedScrollingParent() { 
  36.         return mScrollingChildHelper.hasNestedScrollingParent(); 
  37.     } 
  38.     @Override 
  39.     public boolean dispatchNestedScroll(int dxConsumed, int dyConsumed, int dxUnconsumed, 
  40.                                         int dyUnconsumed, int[] offsetInWindow) { 
  41.         return mScrollingChildHelper.dispatchNestedScroll(dxConsumed, dyConsumed, 
  42.                 dxUnconsumed, dyUnconsumed, offsetInWindow); 
  43.     } 
  44.     @Override 
  45.     public boolean dispatchNestedPreScroll(int dx, int dy, int[] consumed, int[] offsetInWindow) { 
  46.         return mScrollingChildHelper.dispatchNestedPreScroll(dx, dy, consumed, offsetInWindow); 
  47.     } 
  48.     @Override 
  49.     public boolean dispatchNestedFling(float velocityX, float velocityY, boolean consumed) { 
  50.         return mScrollingChildHelper.dispatchNestedFling(velocityX, velocityY, consumed); 
  51.     } 
  52.     @Override 
  53.     public boolean dispatchNestedPreFling(float velocityX, float velocityY) { 
  54.         return mScrollingChildHelper.dispatchNestedPreFling(velocityX, velocityY); 
  55.     } 

代码就不解释了,毕竟这不是本篇文章等重点,大家可以去网上查阅NestedScrollView相关文章或者源码去对照理解。

完整的布局代码比较多,篇幅有限,大家可以去github上面查看:https://github.com/Moosphan/CommentWithReplyView-master

【编辑推荐】

  1. Google 为 Android P 引入新的生物识别身份验证 API
  2. Android屏幕适配很麻烦吗?不!太简单了。。。
  3. 撸起袖子自己写一个Android通用刷新控件
  4. Android简单的短信验证功能的实现
  5. 谷歌发布 Android P Beta 3,已非常接近正式版
【责任编辑:未丽燕 TEL:(010)68476606】

点赞 0
分享:
大家都在看
猜你喜欢

读 书 +更多

开源osCommerce 轻松架设专业电子商务平台

osCommerce是一款免费的、开放源代码的专业电子商务解决方案。本书以通俗易懂的语言向读者展示了该软件强大的功能和简易的操作方法,主要内...

订阅51CTO邮刊

点击这里查看样刊

订阅51CTO邮刊