📱 Android | 应用开发 - Coffee 数据分享系统

这个项目的主题来自于我们之前设计的 Coffee 系统,这是一套数据管理分享系统,原本的设计目标是一套部署于局域网内的 NAS 系统,可以在局域网内高速共享大文件,在线浏览海量相册以及高清电影。但是目前的后端的进度只完成了文字和图片的分享和管理,因此目前的 Coffee for Android 0.1 的定位也就暂时定位在一套类似于微博的图文分享流应用。

本文记录了开发过程中一些设计思路和实现方法。

🚀 代码: Github

后端实现

后端采用的是个人之前使用Go语言写的 Coffee 系统的后端,基本功能上已经足够了,而登陆部分的接口就还需要改动一下

UI 设计

和期中项目一样,这次应用的 UI 设计依然尽量遵循 Material Design 风格,使用其相应的控件并且遵循他的设计推荐和规范设计。

这个应用的名字叫做 Coffee,因此其主题色也选择了咖啡色。

设计初稿:

1547699644771

页面设计

这次的应用主要分成六个部分

  • 登陆注册
    • 依赖于 Violet2.0 中央授权用户系统
  • 主页
    • 动态显示个人或者广场的图文信息流、还有用户的通知
  • 新建/编辑页面
    • 增加或者编辑图文信息
  • 设置页面
    • 管理用于的一些设置,如通知、显示、网络、缓存等
  • 详情页面
    • 显示图文信息的详情以及其评论回复
  • 个人详情
    • 显示用户的所有公开信息

这次我负责的页面是主页、设置、通知以及详情页面四个部分

主页

这个是贯穿整个应用最重要的部分,分为广场、我的、通知三个部分。

并且可以导航到其他的所有页面,是整个应用的中心。

布局

布局的最外层使用的是DrawerLayout+NavigationView的配合实现左边划出的导航菜单。

1547701151419

内层使用TabLayout + ViewPager对三个页面进行切换和导航

再加一个FloatingActionButton用于跳转到新建内容的页面

广场

广场用于显示所有公开的图文信息。

其主体就是一个RecyclerView,并且使用了SmartRefreshLayout作为上拉刷新以及下拉加载更多的控件。

1547701969063

数据采用了分页加载的方式,每次只加载 7 条信息,减少了第一次从服务器获取内容的时间。

下面是上拉刷新数据以及下拉加载更多的逻辑。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 刷新数据
binding.refreshLayout.setOnRefreshListener(refreshLayout -> {
currentPage = 1;
viewModel.getPublic(currentPage, EACH_PAGE).observe(activity, resource -> {
if (resource != null) {
switch (resource.getStatus()) {
case SUCCESS:
setData(Objects.requireNonNull(resource.getData()).getResource());
refreshLayout.finishRefresh(200);
break;
case ERROR:
refreshLayout.finishRefresh(false);
setStatus(ListStatus.StatusType.Error);
break;
}
}
});
});
binding.squareStatus.statusError.setOnClickListener(v -> refreshData());

// 加载更多数据
binding.refreshLayout.setEnableLoadMore(true);
binding.refreshLayout.setOnLoadMoreListener(refreshLayout -> {
if (!hasMore) {
refreshLayout.setEnableLoadMore(false);
} else {
currentPage++;
viewModel.getPublic(currentPage, EACH_PAGE).observe(activity, resource -> {
if (resource != null) {
switch (resource.getStatus()) {
case SUCCESS:
setData(Objects.requireNonNull(resource.getData()).getResource());
refreshLayout.finishLoadMore(200);
break;
case ERROR:
refreshLayout.finishLoadMore(false);
setStatus(ListStatus.StatusType.Error);
break;
}
}
});
}
});
binding.refreshLayout.setRefreshHeader(new DeliveryHeader(activity));

图片显示使用了com.lzy.ninegrid.NineGridView实现

这是一个开源的九宫格图片显示库,它可以实现多于 9 张图片在最后一张图上使用数字显示剩余数量以及点击放大左右滑动浏览图片。

在使用的时候,只需要将图片的大图和小图的 URL 放入其Adapter

1
2
3
4
5
6
7
8
9
10
11
12
13
ArrayList<ImageInfo> imageInfo = new ArrayList<>();
Album album = itemData.getAlbum().getTarget();
if (album != null) {
ToMany<Image> images = album.getImages();
for (Image i :images) {
ImageInfo info = new ImageInfo();
info.setThumbnailUrl(i.getThumb());
// info.setBigImageUrl(i.getThumb());
info.setBigImageUrl(i.getFile().getTarget().getFile() + "@" + itemData.getId());
imageInfo.add(info);
}
}
binding.nineGridImage.setAdapter(new NineGridViewClickAdapter(activity, imageInfo));

然后实现一个ImageLoader,用于将 URL 解析为图片

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class MyImageLoader implements NineGridView.ImageLoader {
private ImageViewModel viewModel;
private SupportActivity activity;

public MyImageLoader(SupportActivity activity, ImageViewModel viewModel) {
this.viewModel = viewModel;
this.activity = activity;
}
@Override
public void onDisplayImage(Context context, ImageView imageView, String url) {
String[] data = url.split("@");
if (data.length == 2) {
String path = data[0];
final ImageView currentImage = imageView;
viewModel.getImage(data[1], path).observe(activity, res -> {
if (res != null) {
switch (res.getStatus()) {
case SUCCESS:
currentImage.setImageBitmap(res.getData());
break;
case ERROR:
Toast.makeText(context,
res.getMessage(), Toast.LENGTH_SHORT).show();
break;
}
}
});
} else if (data.length == 1) {
final ImageView currentImage = imageView;
viewModel.getThumb(data[0]).observe(activity, res -> {
if (res != null && res.getStatus() == Status.SUCCESS) {
currentImage.setImageBitmap(res.getData());
}
});
} else {
imageView.setImageDrawable(activity.getDrawable(R.drawable.ic_like));
}
}
@Override
public Bitmap getCacheImage(String url) {
return null;
}
}

由于这里需要使用到ViewModel获取图片,但是这个需要用在很多不同的页面中,因此需要实现一个接口,用于加载图片

1
2
3
4
public interface ImageViewModel {
LiveData<Resource<Bitmap>> getThumb(String file);
LiveData<Resource<Bitmap>> getImage(String id, String path);
}

一个ViewModel只需要实现了这两个方法,就可以用于MyImageLoader

我的

我的页面显示自己的所有数据。

这里使用了时间轴模式显示

1547721368201

这个时间轴本质上就是一个在RecyclerView中的Item布局的左边画一条直线和一个点,然后将每个项目之间的间距取消,那样看上去就是连在一起的一个整体,整体上的观感就会很好。

和广场页面一样,同样是使用了SmartRefreshLayout来实现上拉刷新

其他的功能和广场页面其实就是差不多的,只是在我的页面中自己的头像和名字就没有必要显示出来,而其他内容是一样的。

通知

通知页面显示用户一些通知,可以通过拖动将New标记取消,表示已读,也可以通过右滑将其删除

1547721549294

New标记通过q.rorbin.badgeview.Badge这个控件实现,可以实现拖曳消除,比较符合一些通知的逻辑。

而右滑删除是使用了com.daimajia.swipe.SwipeLayout这个控件,在布局的时候,把最前面的布局放于最后面,隐藏的布局放于前面,然后在bind的时候,通过setShowModeaddDrag这两个方法将后面的布局加入右滑的动作里面。

具体的实现如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
class ViewHolder extends RecyclerView.ViewHolder {
private final NoticeItemBinding binding;
private Badge badge;

ViewHolder(NoticeItemBinding binding) {
super(binding.getRoot());
this.binding = binding;
this.badge = new QBadgeView(activity)
.bindTarget(binding.noticeAction)
.setBadgeGravity(Gravity.END | Gravity.CENTER)
.setShowShadow(false)
.setBadgeText("New");
}

void bind(int pos) {
NotificationsResource.Notification item = data.get(pos);
if (item.getData().getRead()) {
badge.hide(false);
} else {
badge.setBadgeText("New");
badge.setOnDragStateChangedListener((dragState, badge, targetView) -> {
if (dragState == STATE_SUCCEED) {
onClickNotice.onClickRead(item.getData().getId());
}
});
}
binding.setModel(item);

binding.noticeLayout.setOnClickListener(
v -> DetailActivity.start(activity, item.getData().getTargetId(), null));
binding.noticeUserAvatar.setOnClickListener(
v -> goToUser(item.getData().getSourceId()));
binding.noticeUserName.setOnClickListener(v -> goToUser(item.getData().getSourceId()));
binding.noticeSwipe.setShowMode(SwipeLayout.ShowMode.PullOut);
binding.noticeSwipe.addDrag(SwipeLayout.DragEdge.Right, binding.removeLayout);

binding.noticeDelete.setOnClickListener(v -> onClickNotice.onClickDelete(item.getData().getId()));

binding.noticeTime.setText(getTime(item.getData().getCreateTime()));

viewModel.getUserAvatar(item.getUser().getAvatar()).observe(activity, res -> {
if (res != null && res.getStatus() == Status.SUCCESS) {
binding.noticeUserAvatar.setImageBitmap(res.getData());
}
});
}
}

对于以上的三个列表,都有可能为空或者是因为网络原因无法加载出来,为了方便管理,我添加了一个ListStatus来同一个管理列表的状态,当列表为空或者因为网络问题无法加载或者是在加载中,都可以统一展示信息给用户。

设置

设置页面使用了 Android 原生的PreferenceActivity实现

不需要自己管理列表的布局,只需要定义一些xml,就可以生成一系列的设置页面

1547722932098

pref_headers为设置的根目录,只需要定义多个header以及每一个项对应的类、图标、描述以及标题

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
<preference-headers xmlns:android="http://schemas.android.com/apk/res/android">
<!-- These settings headers are only used on tablets. -->
<header
android:fragment="studio.xmatrix.coffee.
ui.setting.SettingsActivity$NotificationPreferenceFragment"
android:icon="@drawable/ic_notifications_black_24dp"
android:summary="管理应用通知"
android:title="@string/pref_header_notifications" />

<header
android:fragment="studio.xmatrix.coffee.
ui.setting.SettingsActivity$DisplayPreferenceFragment"
android:icon="@drawable/ic_display"
android:summary="调节应用显示"
android:title="@string/pref_header_display" />

<header
android:fragment="studio.xmatrix.coffee.
ui.setting.SettingsActivity$GeneralPreferenceFragment"
android:icon="@drawable/ic_general"
android:summary="应用通用设置"
android:title="@string/pref_header_general" />

<header
android:id="@+id/about"
android:icon="@drawable/ic_about"
android:summary="关于Coffee"
android:title="@string/pref_header_about" />

</preference-headers>

1547723187450

而具体的设置布局使用PreferenceScreen实现,SwitchPreference表示一个开关、PreferenceCategory表示一组设置项,ListPreference表示多选项等等。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android">
<SwitchPreference
android:key="notifications_open"
android:title="通知提醒"
android:summaryOn="当收到以下事件时候,将会通过通知提醒你"
android:summaryOff="你不会收到任何的通知提醒"
android:defaultValue="true"/>
<PreferenceCategory android:title="通知类型">
<SwitchPreference
android:dependency="notifications_open"
android:key="notifications_comment"
android:title="收到评论/回复"
android:defaultValue="true"/>
<SwitchPreference
android:dependency="notifications_open"
android:key="notifications_system"
android:title="系统通知"
android:defaultValue="true"/>
<SwitchPreference
android:dependency="notifications_open"
android:key="notifications_like"
android:title="收到点赞"
android:defaultValue="true"/>
</PreferenceCategory>
</PreferenceScreen>

1547723169390

详情

这个页面中的数据展示是最为复杂的一个页面,它涉及到了具体的内容,以及评论列表,还是每个评论下的回复列表,还有对于内容、评论、回复的点赞、管理、编辑、删除、添加等操作。

1547723277760

逻辑虽然复杂,但是操作还是和一般的列表操作差不多的,这里就不一一详细说明了。

个人页面

个人页面有两种形态,一种是展开的形态,另一种是折叠起来的形态

1547724070077

另一种就是折叠起来的形态,这两种形态是有一定的过渡的,通过CollapsingToolbarLayout可收缩的标题栏来实现,具体的实现方法可以看我们期中项目当中的实现。

1547724158464

其他一些改进

动画

为了整个应用加载的一体性,我在应用中加入了不少的过渡动画。

首先在AppThemeSytles中,设置开启过渡动画windowContentTransitions

1
2
3
4
5
6
7
<style name="AppTheme" parent="Theme.AppCompat.DayNight.DarkActionBar">
<!-- Customize your theme here. -->
<item name="colorPrimary">@color/colorPrimary</item>
<item name="colorPrimaryDark">@color/colorPrimaryDark</item>
<item name="colorAccent">@color/colorAccent</item>
<item name="android:windowContentTransitions">true</item>
</style>

比如在内容卡片跳转到用户详情的时候,通过makeSceneTransitionAnimation添加两个过渡的元素,然后在布局的时候,在需要过渡的两方都添加一个同样的transitionName的属性名。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
<de.hdodenhof.circleimageview.CircleImageView
android:id="@+id/user_avatar"
android:layout_width="32dp"
android:layout_height="32dp"
android:layout_margin="12dp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:src="@mipmap/ic_launcher"
android:transitionName="userAvatar"
android:translationZ="2dp"
app:civ_border_color="@color/colorAvatar"
app:civ_border_width="1dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />

<TextView
android:id="@+id/user_name"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="10dp"
android:textSize="16sp"
android:background="?attr/selectableItemBackgroundBorderless"
android:clickable="true"
android:text="@{model.userName}"
tools:text="MegaShow"
android:textColor="@color/colorBlack"
android:transitionName="userName"
android:translationZ="8dp"
app:layout_constraintBottom_toBottomOf="@id/user_avatar"
app:layout_constraintStart_toEndOf="@id/user_avatar"
app:layout_constraintTop_toTopOf="@id/user_avatar" />

在跳转的时候把需要过渡的元素作为参数放入 Intent 中,就可以使用原生的方法实现过渡

1
2
3
4
5
6
7
8
9
10
void goToUser(String userId) {
UserActivity.start(activity,
userId,
ActivityOptionsCompat
.makeSceneTransitionAnimation(
activity,
Pair.create(binding.noticeUserAvatar, "userAvatar"),
Pair.create(binding.noticeUserName, "userName"))
.toBundle());
}

夜间模式

要使用 Android 夜间模式功能,所用的AppTheme就必须继承于DayNight下面的项

1
<style name="AppTheme" parent="Theme.AppCompat.DayNight.DarkActionBar">

然后在values中添加要给colors-nightxml,然后将夜间的配色添加到里面。

对于一些drawable图形,同样可以放在drawable-night中以实现两种主题中不同的图片和颜色。

1547722469446

然后在应用加载的时候,读取配置中的模式,设置应用的主题

1
2
3
4
5
6
7
8
int currentMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
if (currentMode != Configuration.UI_MODE_NIGHT_YES) {
item.setIcon(getDrawable(R.drawable.ic_night));
item.setTitle("夜间模式");
} else {
item.setIcon(getDrawable(R.drawable.ic_day));
item.setTitle("日间模式");
}

然后在切换的时候通过AppCompatDelegate设置模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
 int currentMode = getResources().getConfiguration().uiMode & Configuration.UI_MODE_NIGHT_MASK;
if (currentMode != Configuration.UI_MODE_NIGHT_YES) {
//保存夜间模式状态,Application中可以根据这个值判断是否设置夜间模式
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_YES);
//ThemeConfig主题配置,这里只是保存了是否是夜间模式的boolean值
NightModeConfig.getInstance().setNightMode(getApplicationContext(), true);
item.setIcon(getDrawable(R.drawable.ic_day));
item.setTitle("日间模式");
} else {
AppCompatDelegate.setDefaultNightMode(AppCompatDelegate.MODE_NIGHT_NO);
NightModeConfig.getInstance().setNightMode(getApplicationContext(), false);
item.setIcon(getDrawable(R.drawable.ic_night));
item.setTitle("夜间模式");
}
//需要recreate才能生效
drawer.closeDrawer(GravityCompat.START);
recreate();

在设置中,同样可以设置是否自动启用夜间模式

1
2
3
4
5
6
SwitchPreference nightAuto = (SwitchPreference) findPreference("night_auto");
nightAuto.setOnPreferenceChangeListener((preference, newValue) -> {
boolean isAuto = (boolean) newValue;
NightModeConfig.getInstance().setNightAuto(getActivity(), isAuto);
return true;
});

1547724109555

1547724134345

总结

这次的项目和期中的项目一样,我依旧是负责总体的交互逻辑和 UI 的设计以及操作逻辑的编写。因为有了之前的一些经验,这次项目做起来也是熟路轻车的。

至于后端的编写,使用的是Go语言,是一个经典的Restful API的后端。

这个应用和上次不同的是,这次应用引入了设置、导航这些一般的安卓应用中常见的部件,也学会了他们的一些简单的用法。

在代码的编写上面将很多重复的部分提取了出来,一些布局也进行了复用、比如列表中的内容卡片和详情中的内容卡片是属于同一个布局,我的页面的列表和每一个用户的列表也是使用的是同一个布局和逻辑,通过一些类将其封装起来,一次编写,到处使用,在后期维护起来也简便了很多。

土豪通道
0%