乐趣区

关于android:账本APP开发

好好学习,天天向上

本文已收录至我的 Github 仓库 DayDayUP:github.com/RobodLee/DayDayUP,欢送 Star,更多文章请返回:目录导航

前言

我平时喜爱用账本记一记我的日常生产而后做个总结,始终用本子记不是很不便,从网上下载的 APP 又太臃肿,很多性能都用不到,就想着能不能自己开发一个 APP。刚好最近学完了 SpringBoot,我之前也学习过 Android,就花了一段时间开发完了。原本认为没什么难度,但还是遇到了一些比拟辣手的问题,所以就打算写篇文章总结一下。先给大家看一下我的界面:

ps:一篇文章不可能写的八面玲珑,我只写一下大体的思路以及我所遇到的问题,具体的代码我放在了 GitHub 上,有趣味的小伙伴请自行浏览。
账本 APP


后期筹备

既然是做账本,那么必定免不了要用到数据库,我用的 Android 自带的 SQLite 数据库,为了简化数据库的操作,我应用了 LitePal 框架;还有一点,我不想在我的代码里写一大堆 findViewById, 这样会显得代码十分的乱,所以我还应用了 xUtils 框架去简化我的代码;数据有了,天然要存储到服务器中,免不了要用到 HTTP,这里我抉择的是赫赫有名的 OkHttp 框架;既然要传输数据,那么我抉择了 json 格局去传输数据,解析 json 我应用了阿里的 FastJson 框架,因为我感觉 FastJson 用起来还是比拟棘手的;为了省去一堆的 getter 和 setter 办法,我还应用了十分好用的 Lombok 插件。


一、增加以及批改记录的性能实现

最先实现的性能应该是如何增加记录了,而后再去思考怎么将数据展现进去。我在这个 Activity 外面集成了增加以及批改数据的性能,我用了一个 boolean 值“toModify”去判断以后操作性能,是增加数据的话点击保留就会增加记录,否则就会批改数据,首先必定要有个 Record 类, 每个字段的作用都在正文里了。

我一开始用的不是 Date 而是 LocalDateTime,而后我从数据库依据工夫查问数据的时候始终查问不到数据,原本我认为是查问局部写的不对,后果到处数据库一看,date 基本就没有值。我又认为是我在增加数据的时候写的有问题,而后想了一下,其它字段都没问题,怎么唯独 date 有问题,而后我换成了 Date 就能够失常保留了。这里要提一点,就是 SQLite 的主键必须是 int 型,我原本 id 是 Stirng 型的,值就是 uuid 字符串,后果不行,我就改成了 int,而后再加一个 String 类型的 uuid 字段去做数据的惟一标识符。

public class Record extends LitePalSupport implements Serializable {

    private int id;             // 主键
    private String category;    // 分类的名称
    private String content;     // 备注
    private double money;       // 金额, 大于等于 0 代表支出,用绿色示意; 小于 0 代表收入,用红色示意,等于 0 用灰色示意
    // 状态,0: 已同步到服务器,1: 未同步到服务器,// 2: 之前同步到服务器当初本地删除,同步时让服务器删除这条记录,3. 之前同步到服务器当初在本地批改,同步时让服务器批改这条记录
    private int status;
    private Date date;          // 记录的日期
    private String dateString;  // 日期的字符串 2020-04-01
    private String uuid;        // 每条记录的惟一标识符

}

我先来剖析一下我的界面形成:

首先界面上有一些分类的图标,有收入和支出两页,我是用 ViewPager 去实现两页的切换,展现分类图标我应用了 RecyclerView,上面有一个抉择工夫的控件,用的是 DatePickerDialog,其它的就是一些 TextView,Button 什么的。

再来介绍一下性能:点击分类图标的时候,上面对应的就会呈现分类的名称,而后输出金额(负数),点击右上角的保留按钮时就会去判断以后分类是支出还是收入,如果是收入的话就将金额 *(-1),这样就变成了正数,保留胜利就会 finish 掉以后流动回到主页面,失败的话会弹出一个 Toast。

Date date = new Date(year - 1900, month - 1, day);
boolean saveSuccess;
String categoryNameStr = categoryName.getText().toString();
String contentStr = contentEdit.getText().toString();
if (toModify) {Log.d(TAG, toModifyRecord.toString());
    toModifyRecord.setCategory(categoryName.getText().toString());
    toModifyRecord.setContent(contentEdit.getText().toString());
    double money = Double.parseDouble(moneyEdit.getText().toString());
    toModifyRecord.setMoney(isIncome ? (money) : (money * -1));
    toModifyRecord.setStatus(((toModifyRecord.getStatus() == 0) ? (3) : (1)));
    toModifyRecord.setDate(date);
    toModifyRecord.setDateString(dateString);
    saveSuccess = toModifyRecord.save();
    Log.d(TAG, toModifyRecord.toString());
} else {Record record = new Record();
    record.setCategory((!TextUtils.isEmpty(categoryNameStr) ? (categoryNameStr) : ("无")));
    record.setContent((!TextUtils.isEmpty(contentStr) ? (contentStr) : ("无")));
    double money = Double.parseDouble(moneyEdit.getText().toString());
    record.setMoney(isIncome ? (money) : (money * -1));
    record.setStatus(1);    // 1 代表未同步到服务器
    record.setDate(date);
    record.setUuid(UuidUtil.getUuid());
    record.setDateString(dateString);
    saveSuccess = record.save();}
if (saveSuccess) {ToastUtil.Pop("保留胜利");
    finish();} else {ToastUtil.Pop("保留失败");
}

这个 toModifyRecord 就是须要批改的数据,是在主页面中点击批改而后传入一个 uuid,再依据 uuid 查出来的:

@Override
protected void onCreate(Bundle savedInstanceState) {
    
    ············
    try {toModifyRecord = LitePal.where("uuid = ?", getIntent().getStringExtra("uuid")).find(Record.class).get(0);
    } catch (Exception e) {//e.printStackTrace();
    }
    toModify = toModifyRecord != null;
    
    ············
}

看这行代码的敌人们可能有点纳闷,我为什么不间接传个对象过去而是拿着 uuid 再去数据库中查一遍,这不是有点多此一举吗,在《第一行代码》中提到过这样一段话:

首先,最简略的一种更新形式就是对已存储的对象从新设值,而后从新调用 save() 办法即可。那么这里咱们就要理解一个概念,什么是已存储的对象?
对于 LitePal 来说,对象是否已存储就是依据调用 model.isSaved() 办法的后果来判断的,返回 true 就示意已存储,返回 false 就示意未存储。那么接下来的问题就是,什么状况下会返回 true,什么状况下会返回 false 呢?实际上只有在两种状况下 model.isSaved() 办法才会返回 true,一种状况是曾经调用过 model.save() 办法去增加数据了,此时 model 会被认为是已存储的对象。另一种状况是 model 对象是通过 LitePal 提供的查问 API 查出来的,因为是从数据库中查到的对象,因而也会被认为是已存储的对象。

意思就是我如果间接传一个对象的实例过去,那么这个对象就是数据库中的对象的拷贝,并不是数据库中的对象的援用,我如果用对象的拷贝去执行 save() 办法是不会对数据库中的数据产生影响,那就不对了,所以我须要拿着 uuid 再去从数据库中把数据查问进去,再去调用 save() 办法才有用。如果没查到就阐明我没有传 uuid 过去,那么就是去应用增加数据的性能。

二、数据的展现

说完了数据的保留再来说一下数据的展现,这是整个 APP 比拟外围的性能。标题栏上有一个显示以后月份的 TextView,点击的话会呈现一个图示的日期抉择控件。至于数据的展现呢,我应用了 RecyclerView 嵌套 ListView 去实现的,RecyclerView 的每一个子项是每一天记录的汇合,每一天的数据是用一个 ListView 去展现的。我在做这个界面的时候遇到了一个问题,就是始终只有一条数据显示,我原本认为是我数据没增加上,后果导出来一看是有数据的。我就始终在调试代码,怎么都没看出问题,而后往上一滑才发现原来是一条数据占满了屏幕。原来是 RecyclerView 嵌套 ListView 的时候 ListView 不晓得本人的高度,须要从新测量高度,我在网上找到了解决方案:

// 从新计算 ListView 的高度
public static void setListViewHeightBasedOnChildren(ListView listView) {
    // 获取 ListView 对应的 Adapter
    ListAdapter listAdapter = listView.getAdapter();
    if (listAdapter == null) {return;}
    int totalHeight = 0;
    for (int i = 0, len = listAdapter.getCount(); i < len; i++) {// listAdapter.getCount() 返回数据项的数目
        View listItem = listAdapter.getView(i, null, listView);
        // 计算子项 View 的宽高
        listItem.measure(0, 0);
        // 统计所有子项的总高度
        totalHeight += listItem.getMeasuredHeight();}
    // listView.getDividerHeight() 获取子项间分隔符占用的高度
    // params.height 最初失去整个 ListView 残缺显示须要的高度
    ViewGroup.LayoutParams params = listView.getLayoutParams();
    params.height = totalHeight + (listView.getDividerHeight() * (listAdapter.getCount() - 1));
    listView.setLayoutParams(params);
}

怎么从数据库中查问出数据而后展现进去废了我好一番劲,好在问题完满地解决了。当初就来说一下我的具体实现过程吧,先把外围的代码贴出来:

/**
 * 用于更新界面信息
 */
private void upgradeMainList() {thisMonthAllRecords.clear();
    thisMonthAllRecords = LitePal.where("dateString like ? and status != ?", +mYear[0] + "-" +
            ((mMonth[0] < 10) ? ("0" + mMonth[0]) : (mMonth[0])) + "%", "2")
            .find(Record.class);
    if (thisMonthAllRecords != null) {Map<String, List<Record>> map = new HashMap<>((int) (thisMonthAllRecords.size() / 0.75) + 1);
        for (Record record : thisMonthAllRecords) {List<Record> staList = map.get(record.getDateString());
            if (staList == null) {staList = new ArrayList<>();
            }
            staList.add(record);
            Collections.sort(staList, new Comparator<Record>() {
                @Override
                public int compare(Record record1, Record record2) {return (record1.getDateString())
                            .compareTo(record2.getDateString());
                }
            });
            map.put(record.getDateString(), staList);
        }
        Set<String> set = map.keySet();
        List<List<Record>> thisMonthRecordsByDay = new ArrayList<>();   // 按每一天离开的 List<List> 汇合
        for (String s : set) {List<Record> list = map.get(s);
            thisMonthRecordsByDay.add(list);
        }
        LinearLayoutManager layoutManager = new LinearLayoutManager(this);
        recyclerView.setLayoutManager(layoutManager);
        RecyclerViewAdapter recyclerAdapter = new RecyclerViewAdapter(thisMonthRecordsByDay);
        recyclerView.setAdapter(recyclerAdapter);
    }
}

首先从数据库中查问出符合条件的数据,封装成一个 List 汇合,而后对 List 汇合进行排序,排完序后把 List 汇合封装成 List<List<Record>> thisMonthRecordsByDay, 每个子项是一天的记录的汇合。而后就创立了一个自定义的 RecyclerViewAdapter 的实例,在 RecyclerViewAdapter 中绑定数据的时候再去创立 ListViewAdapter 的实例进行每一天的数据的展现。具体代码我就不贴了,看我的源码就晓得了。

当初就是每条记录的点击事件了,我是在 RecyclerViewAdapter 中的 onCreateViewHolder() 办法中进行事件绑定的。

holder.todayRecordList.setOnItemLongClickListener(new AdapterView.OnItemLongClickListener() {
    @Override
    public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) {final Record selectRecord = oneMonthRecordsByDay.get(holder.getAdapterPosition()).get(position);
        final View popView = LayoutInflater.from(MainActivity.this).inflate(R.layout.pop_dialog_view, null);
        popView.findViewById(R.id.pop_modification).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {popDialog.dismiss();
                Intent intent = new Intent(MainActivity.this, AddAndModifyRecordActivity.class);
                intent.putExtra("uuid", selectRecord.getUuid());
                startActivity(intent);
            }
        });
        popView.findViewById(R.id.pop_delete).setOnClickListener(new View.OnClickListener() {
            @Override
            public void onClick(View v) {if (selectRecord.getStatus() == 0) {selectRecord.setStatus(2);
                    selectRecord.save();} else {selectRecord.delete();
                }
                popDialog.dismiss();
                upgradeMainList();}
        });
        popDialog = new AlertDialog.Builder(MainActivity.this)
                .setView(popView)
                .create();
        popDialog.show();
        return true;
    }
});

长按记录的时候会弹出两个选项,一个是删除,一个是批改,点击批改的话会进入到方才增加记录的 Activity 中进行数据的批改;点击删除的话首先会去判断 status 的值是不是 0,0 代表该条记录之前曾经同步到服务器中,当初改成 2,代表下次同步的时候把该条记录在服务器中删除后再在本地删除,如果不是间接删了就好。

三、数据同步性能

当初就来说最初一个性能了。如果数据始终保留在本地的话,那么数据迟早有一天会失落的,所以应该有个同步性能将数据保留在服务器里,这样数据就不会失落了。服务器端的开发我会放在下一篇文章里,当初先来看看客户端的具体实现。界面很简略,只有两个按钮,一个是同步到云端,一个是下载到本地,来看一下具体的实现:

1. 上传到云端性能:

final List<Record> toUpgradeRecords = LitePal.where("status > ?", "0").find(Record.class);
HttpUtil.uploadRecords(ip + Constant.UPLOAD_RECORDS, phoneNumber, toUpgradeRecords, new Callback() {
    @Override
    public void onFailure(Call call, IOException e) {showToast("服务器异样");
        finish();}
    @Override
    public void onResponse(Call call, Response response) throws IOException {String responseBody = response.body().string();
        ResultInfo resultInfo = JSONObject.parseObject(responseBody, ResultInfo.class);
        if (resultInfo.isFlag()) {for (Record record : toUpgradeRecords) {if (record.getStatus() != 2) {record.setStatus(0);
                    record.save();} else {record.delete();
                }
            }
            showToast("同步胜利");
            finish();} else {showToast("同步失败");
            finish();}
    }
});

首先查问出未同步到云端的记录汇合,而后调用 HttpUtil.uploadRecords() 办法去传输数据,来看一下 HttpUtil.uploadRecords() 办法干了什么吧:

public static void uploadRecords(String address, String phoneNumber , List<Record> toUpgradeRecords , okhttp3.Callback callback) {OkHttpClient client = new OkHttpClient();
    RequestBody requestBody = new FormBody.Builder()
            .add("phoneNumber",phoneNumber)
            .add("recordsJson" , JSON.toJSONString(toUpgradeRecords))
            .build();
    Request request = new Request.Builder()
            .url(address)
            .post(requestBody)
            .build();
    client.newCall(request).enqueue(callback);
}

就是把 List 汇合用 FastJson 转换成 Json 字符串,而后和 phoneNumber 一起发送给服务器,用的是 POST 申请,而后就会回到后面的回调当中去处理结果,如果是同步胜利的话,就去判断每条记录的 status 值,该干啥干啥,失败的话就给个提醒而后 finish 掉以后的 Activity。

2. 下载到本地性能实现:

看完了上传性能再来看一下下载性能:

String address = ip + Constant.DOWNLOAD_RECORDS + phoneNumber;
HttpUtil.sendOkHttpGetRequest(address, new Callback() {
    @Override
    public void onFailure(Call call, IOException e) {showToast("服务器异样");
        finish();}
    @Override
    public void onResponse(Call call, Response response) throws IOException {String responseBody = response.body().string();
        ResultInfo resultInfo = JSONObject.parseObject(responseBody, ResultInfo.class);
        Log.d(TAG, "onResponse:" + resultInfo.getData().toString());
        List<Record> recordsDownloadFormServer = JSONArray.parseArray(resultInfo.getData().toString(), Record.class);
        if (resultInfo.isFlag()) {for (Record record : recordsDownloadFormServer) {
                try {LitePal.where("uuid = ?", record.getUuid()).find(Record.class).get(0);
                } catch (Exception e) {e.printStackTrace();
                    record.setDateString(ConvertUtils.dateToString(record.getDate()));
                    record.save();}
            }
            showToast("下载胜利");
        }
        finish();}
});

首先应用 HttpUtil.sendOkHttpGetRequest() 办法将申请发送到服务器,用的是 get 申请,服务器会依据传过来的 phoneNumber 将该用户的所有记录都传回来,传过来的记录汇合也是 Json,所以先用 FastJson 解析成 List。而后循环遍历 List 判断该条记录在本地有没有,有的话就跳过,没有的话就保留到 SQLite 中。

总结

到此为止,整个账本 APP 的外围性能和一些我所遇到的问题以及解决办法都曾经写完了,其它的一些比较简单的性能我就不再赘述。文章写的不是很好,大家如果不称心的话间接关掉就好,或者是在上面留言,谢谢!

退出移动版