承上启下:重构 Markdown 笔记应用 MarkNote

一、关于项目

MarkNote 是一款 Android 端的笔记应用,它支持很是多的 Markdown 基础语法,还包括了 MathJax, Html 等各类特性。此外,你还能够从相机或者相册中选择图象并将其添加到本身的笔记中。这很酷!由于你能够将本身的游记或者其余图片拍摄下来并将其做为本身笔记的一部分。这也是笔者开发这款软件的目的——但愿 MarkNote 可以成为一款帮助用户记录本身生活的笔记应用。android

下面是我本身制做的一张部分功能预览图。这里仅仅列举了其中的部分页面,固然,你能够在酷安网或者 Google Play Store 上面获取到这个应用程序,并进一步了解它的所有功能,也能够在 Github 上获得最新版的应用的所有源代码。git

预览图

项目相关的连接github

  1. 酷安网下载连接:https://www.coolapk.com/apk/178276
  2. Google Play Store 下载:https://play.google.com/store/apps/details?id=me.shouheng.notepal
  3. Github 项目连接:https://github.com/Shouheng88/MarkNote

最后,之因此把此次重构称为 “承上启下” 的一个很重要的缘由是:此次重构代码实际上是为了后续功能的开发铺路。在将来,我会为这个应用增长更多有趣的功能。若是你对该项目感兴趣的话,能够 Star 或者 Fork 该项目,并为项目贡献代码。咱们欢迎任何的、即便很小的贡献 :)数据库

二、关于重构

在以前的版本中,MarkNote 在功能、界面和代码方面都存在一些不足,因此,前些日子我又专门抽了些时间对这些不足的地方进行了一些优化,时间大概从 11 月中旬直到 12 月中旬。此次重构也进行了大量的代码优化。通过此次重构,项目增长了大概 100 屡次 commit. 下面咱们列举一下本次重构所涉及的部分,其实也是这段时间以来学习到的东西的一些总结。设计模式

2.1 项目结构优化

2.1.1 包结构优化

首先,在以前笔者已经对项目的整个结构作了一次调整,主要是将项目中各个模块的位置进行了调整。这部份内容主要是项目中的 Gradle 配置和项目文件的路径的修改。在 settings.gradle 里面,我按照下面的方式指定了依赖的各个模块的路径:微信

include ':app', ':commons', ':data', ':pinlockview', ':fingerprint'
project(':commons').projectDir = new File('../commons')
project(':data').projectDir = new File('../data')
project(':pinlockview').projectDir = new File('../pinlockview')
project(':fingerprint').projectDir = new File('../fingerprint')
复制代码

这种方式最大的好处就是,项目中的 app, commons, data 等模块的文件路径处于相同的层次中,即:网络

--MarkNote
     |----client
     |----commons
     |----data
     ....
复制代码

这个调整固然是为了组件化开发作准备啦,固然这样的结构相比于将各个模块所有放置在 client 下面清晰得多。app

其次,我将项目中已经比较成熟的部分打包成了 aar,并直接引用该包,而不是继续将其做为一个依赖的形式。这样又进一步简化了项目的结构。dom

最后是项目中的功能模块的拆分。在以前的项目中,Markdown 编辑器和解析、渲染相关的代码都被我放置在项目所引用的一个模块中。而此次,我直接将这个部分拆成了一个单独的项目并将其开源到了 Github.异步

EasyMark

这么作的主要目的是:

  1. 将核心的功能模块从项目中独立出来单独开发,以实现更多的功能并提高该部分的性能;
  2. 开源,但愿可以帮助想实现一个 Markdown 笔记的开发者快速集成这个功能;
  3. 开源,但愿可以有开发者参与进行以提高这部分的功能。

关于 Markdown 处理的部分被开源到了 Github,其地址是:github.com/Shouheng88/… ,该项目中同时还包含了一个很是好用的编辑器菜单控件,感兴趣的同窗能够关注一下这个项目。

2.1.2 MVVM 调整

在该项目中,咱们一直使用的是最新的 MVVM 设计模式,只是惋惜的是在以前的版本中,笔者对 MVVM 的理解不够深刻,因此致使程序的结构更像是 MVP. 本次,咱们对这个部分作了优化,使其更符合 MVVM 设计原则。

以笔记列表界面为例,当咱们获取了对应于 Fragment 的 ViewModel 以后,咱们统一在 addSubscriptions() 方法中对其通知进行订阅:

viewModel.getMutableLiveData().observe(this, resources -> {
        assert resources != null;
        switch (resources.status) {
            case SUCCESS:
                adapter.setNewData(resources.data);
                getBinding().ivEmpty.showEmptyIcon();
                break;
            case LOADING:
                getBinding().ivEmpty.showProgressBar();
                break;
            case FAILED:
                ToastUtils.makeToast(R.string.text_failed);
                getBinding().ivEmpty.showEmptyIcon();
                break;
        }
    });
复制代码

这里返回的 resources,是封装的 Resource 的实例,是用来向观察者传递程序执行结果的包装类。而后,咱们会使用 ViewModel 的 fetchMultiItems() 方法来根据以前传入的页面的状态信息拉取笔记记录:

public Disposable fetchMultiItems() {
    if (mutableLiveData != null) {
        mutableLiveData.setValue(Resource.loading(null));
    }
    return Observable.create((ObservableOnSubscribe<List<NotesAdapter.MultiItem>>) emitter -> {
        List<NotesAdapter.MultiItem> multiItems = new LinkedList<>();
        List list;
        if (category != null) {
            switch (status) {
                case ARCHIVED: list = ArchiveHelper.getNotebooksAndNotes(category);break;
                case TRASHED: list = TrashHelper.getNotebooksAndNotes(category);break;
                default: list = NotebookHelper.getNotesAndNotebooks(category);
            }
        } else {
            switch (status) {
                case ARCHIVED: list = ArchiveHelper.getNotebooksAndNotes(notebook);break;
                case TRASHED: list = TrashHelper.getNotebooksAndNotes(notebook);break;
                default: list = NotebookHelper.getNotesAndNotebooks(notebook);
            }
        }
        for (Object obj : list) {
            if (obj instanceof Note) {
                multiItems.add(new NotesAdapter.MultiItem((Note) obj));
            } else if (obj instanceof Notebook) {
                multiItems.add(new NotesAdapter.MultiItem((Notebook) obj));
            }
        }
        emitter.onNext(multiItems);
    }).observeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(multiItems -> {
        if (mutableLiveData != null) {
            mutableLiveData.setValue(Resource.success(multiItems));
        }
    });
}
复制代码

从上面也能够看出,咱们将从数据库中获取到数据的许多逻辑放在了 ViewModel 中,而且每当想要拉取数据的时候调用一下 fetchMultiItems() 方法便可。这样,咱们能够大大地减小 View 层的代码量。 View 层的逻辑也所以变得清晰得多。

2.2 界面优化:更纯粹的质感设计

记得在 Material Design 刚推出的时候,笔者和许多其余开发者同样兴奋。不过,在实际的开发过程当中我却老是感受不得要领,总觉少了一些什么。不过,通过前段时间的学习,我对在应用中实现质感设计有了更多的认识。

2.2.1 Toolbar 的阴影效果

在以前的版本中,为了实现工具栏下面的阴影效果,我使用了在 Toolbar 下面增长一个高度为 5dp 的控件并为设置一个渐变背景的实现方式。这种实现方式能够完美兼容 Android 系统的各个版本。可是,这种实现的效果没有系统自带的显得那么天然。在新的版本中,我使用了下面的方式来实现阴影的效果:

<android.support.design.widget.CoordinatorLayout
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:fitsSystemWindows="true"
    tools:context=".activity.SearchActivity">

    <me.shouheng.commons.widget.theme.SupportAppBarLayout
        android:id="@+id/bar_layout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="?attr/colorPrimary">

        <android.support.v7.widget.Toolbar
            android:id="@+id/toolbar"
            android:layout_width="match_parent"
            android:layout_height="?attr/actionBarSize"
            android:background="?attr/colorPrimary"/>

    </me.shouheng.commons.widget.theme.SupportAppBarLayout>

...
复制代码

这里的 SupportAppBarLayout 继承自支持包的 AppBarLayout,主要用来实现日夜间主题的兼容。这样 Toolbar 下面就会带有一个漂亮的阴影,可是在比较低版本的手机上面是没有效果的,因此,为了兼容低版本的手机还要使用以前的那种使用控件填充的方式。(在新版本中暂时没有作这个处理)

2.2.2 日夜间主题兼容

在以前的项目中,支持 20 多种主题颜色和强调色,不过最近随着 Google 在本身的项目中逐渐采用纯白色的设计,我也抛弃了以前的逻辑。如今整个项目中只支持三种主题:

  1. 白色的主题 + 蓝色的强调色
  2. 白色的主题 + 粉红的强调色
  3. 黑色的主题 + 蓝色的强调色

主题

对于主题的支持,我依然延续了以前的实现方式——经过重建 Activity 来实现主题的切换。同时,为了达到某些控件随着主题自适应调整的目的,我定义了一些自定义控件,并在其中根据当前的设置选择使用的颜色。而对于其余能够直接使用项目中的强调色或者主题色的部分,咱们能够直接使用当前的主题的值,好比下面的 Toolbar 的背景颜色会使用当前主题中的 主题色

<android.support.v7.widget.Toolbar
    android:id="@+id/toolbar"
    android:layout_width="match_parent"
    android:layout_height="?attr/actionBarSize"
    android:background="?attr/colorPrimary"/>
复制代码

2.2.3 启动页优化

以前的版本中在第一次打开程序的时候会有一个启动页来展现程序的功能,新版本中直接移除了这个功能。取而代之的是使用启动页来进行优化,首秀定义一个主题。这个主题只应用于第一次打开的 Activity。

<style name="AppTheme.Branded" parent="LightThemeBlue">
    <item name="colorPrimaryDark">#00a0e9</item>
    <item name="android:windowBackground">@drawable/branded_background</item>
</style>
复制代码

这里,咱们将界面的背景更换成咱们本身的项目的图标,由于项目图标中使用的颜色与状态栏的颜色不一致,因此,这里又重写了 colorPrimaryDark 属性以将状态栏的颜色和启动页的颜色设置成相同的效果:

<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
    <item>
        <color android:color="#00a0e9"/>
    </item>
    <item>
        <bitmap
            android:src="@drawable/mn"
            android:tileMode="disabled"
            android:gravity="center"/>
    </item>
</layer-list>
复制代码

这种实现方式的效果是,在程序打开的时候不会存在白屏。以前的白屏会被咱们指定的启动页替换掉(由于这个启动页是该 Activity 的窗口的背景)。固然,当页面打开完毕以后你还要在程序中将启动页背景替换掉。这样优化以后程序打开的时候显得更加天然、流畅。

2.2.4 动画优化

由于时间的缘由,在当前的版本中,我并无加入太多的动画,而只是对程序中的一些地方增长了动画的效果。

在笔记的列表中,我使用了下面的动画效果。这样当打开列表界面的时候各个条目会存在自底向上的进入动画。

private int lastPosition = -1;

@Override
protected void convert(BaseViewHolder helper, MultiItem item) {
    // ... 
    /* Animations */
    if (PalmUtils.isLollipop()) {
        setAnimation(helper.itemView, helper.getAdapterPosition());
    } else {
        if (helper.getAdapterPosition() > 10) {
            setAnimation(helper.itemView, helper.getAdapterPosition());
        }
    }
}

private void setAnimation(View viewToAnimate, int position) {
    if (position > lastPosition) {
        Animation animation = AnimationUtils.loadAnimation(mContext, R.anim.anim_slide_in_bottom);
        viewToAnimate.startAnimation(animation);
        lastPosition = position;
    }
}
复制代码

不过,这种方式实现的并非最理想的效果,由于当打开页面的时候,多条记录会以一个总体的形式进入到页面中。这也是之后的一个优化的地方。

2.3 使用 RxJava 重构

在以前的项目中,当进行异步的操做的时候,须要定义一个 AsyncTask. 这种实现方式存在一个明显的问题,当须要执行的异步任务比较多,又没法进行复用的时候,你须要定义大量的 AsyncTask。另外,在各个页面之间进行数据传递的时候,若是单纯地使用 onActivityResult() 或者进行接口回调(Fragment 和 Activity 之间)会使得代码繁琐、难以阅读。针对这些问题,咱们可使用 RxJava 来进行很好的优化。

首先是异步操做的问题,咱们可使用 RxJava 来实现线程的切换。如下面的这段代码为例,它被用来实现保存快速笔记的结果到文件系统和数据库中。在这段代码中,咱们使用了 RxJava 的 create() 方法,并在其中进行逻辑的处理,而后使用 subscribeOn() 方法指定处理的线程是 IO 线程,并使用 observeOn() 方法指定最终处理的结果在主线程中进行处理:

public Disposable saveQuickNote(@NonNull Note note, QuickNote quickNote, @Nullable Attachment attachment) {
    return Observable.create((ObservableOnSubscribe<Note>) emitter -> {
        /* Prepare note content. */
        String content = quickNote.getContent();
        if (attachment != null) {
            attachment.setModelCode(note.getCode());
            attachment.setModelType(ModelType.NOTE);
            AttachmentsStore.getInstance().saveModel(attachment);
            if (Constants.MIME_TYPE_IMAGE.equalsIgnoreCase(attachment.getMineType())
                    || Constants.MIME_TYPE_SKETCH.equalsIgnoreCase(attachment.getMineType())) {
                content = content + "![](" + quickNote.getPicture() + ")";
            } else {
                content = content + "[](" + quickNote.getPicture() + ")";
            }
        }
        note.setContent(content);
        note.setTitle(NoteManager.getTitle(quickNote.getContent(), quickNote.getContent()));
        note.setPreviewImage(quickNote.getPicture());
        note.setPreviewContent(NoteManager.getPreview(note.getContent()));

        /* Save note to the file system. */
        String extension = UserPreferences.getInstance().getNoteFileExtension();
        File noteFile = FileManager.createNewAttachmentFile(PalmApp.getContext(), extension);
        try {
            Attachment atFile = ModelFactory.getAttachment();
            FileUtils.writeStringToFile(noteFile, note.getContent(), Constants.NOTE_FILE_ENCODING);
            atFile.setUri(FileManager.getUriFromFile(PalmApp.getContext(), noteFile));
            atFile.setSize(FileUtils.sizeOf(noteFile));
            atFile.setPath(noteFile.getPath());
            atFile.setName(noteFile.getName());
            atFile.setModelType(ModelType.NOTE);
            atFile.setModelCode(note.getCode());
            AttachmentsStore.getInstance().saveModel(atFile);
            note.setContentCode(atFile.getCode());
        } catch (IOException e) {
            emitter.onError(e);
        }

        /* Save note. */
        NotesStore.getInstance().saveModel(note);

        emitter.onNext(note);
    }).subscribeOn(Schedulers.io()).observeOn(AndroidSchedulers.mainThread()).subscribe(note1 -> {
        if (saveNoteLiveData != null) {
            saveNoteLiveData.setValue(Resource.success(note1));
        }
    });
}
复制代码

另外是界面之间的结果传递的问题。对于 onActivityResult() 的执行结果,咱们使用自定义的 RxBus 来传递信息,它的做用相似于 EventBus。而后,咱们为此而封装了一个 RxMessage 对象来包装返回的结果。可是在程序中,咱们尽可能来简化和减小这种代码,由于过多的全局消息会让代码调试变得更加困难。咱们但愿代码逻辑更加简单、清晰。

RxJava 除了可以完成线程切换的任务以外,对代码的可读性的提高效果也是很是明显的。另外,它还很是适用于局部的优化,好比,咱们能够很轻易地改变本身的代码来将某个耗时逻辑放在异步线程中执行来提高界面的响应速度。

2.4 增长新功能

2.4.1 桌面快捷方式

桌面快捷方式并非全部的 Android 桌面都支持的,咱们在程序中有两个地方使用它。以下图所示,第一种方式是在笔记内部点击建立快捷方式的时候在桌面建立应用的快捷方式,咱们能够经过点击快捷方式来快速打开笔记;第二种方式是长按应用图标的时候弹出一个菜单选项。

快捷方式

首先,第一种实现方式是在 7.0 以后加入的,以前咱们也是能够建立快捷方式的,只是实现的方式与如今的方式不一样而已。以下面这段代码所示,当 7.0 以后,咱们使用 ShortcutManager 来建立快捷方式。以前,咱们可使用 "com.android.launcher.action.INSTALL_SHORTCUT" 这个 ACTION 并指定参数来建立快捷方式:

public static void createShortcut(Context context, @NonNull Note note) {
    Context mContext = context.getApplicationContext();
    Intent shortcutIntent = new Intent(mContext, MainActivity.class);
    shortcutIntent.putExtra(SHORTCUT_EXTRA_NOTE_CODE, note.getCode());
    shortcutIntent.setAction(SHORTCUT_ACTION_VIEW_NOTE);

    if (VERSION.SDK_INT >= VERSION_CODES.N_MR1) {
        ShortcutManager mShortcutManager = context.getSystemService(ShortcutManager.class);
        if (mShortcutManager != null && VERSION.SDK_INT >= VERSION_CODES.O) {
            if (mShortcutManager.isRequestPinShortcutSupported()) {
                ShortcutInfo pinShortcutInfo = new Builder(context, String.valueOf(note.getCode()))
                        .setShortLabel(note.getTitle())
                        .setLongLabel(note.getTitle())
                        .setIntent(shortcutIntent)
                        .setIcon(Icon.createWithResource(context, R.drawable.ic_launcher_round))
                        .build();

                Intent pinnedShortcutCallbackIntent = mShortcutManager.createShortcutResultIntent(pinShortcutInfo);

                PendingIntent successCallback = PendingIntent.getBroadcast(context, /* request code */ 0,
                        pinnedShortcutCallbackIntent, /* flags */ 0);

                mShortcutManager.requestPinShortcut(pinShortcutInfo, successCallback.getIntentSender());
            }
        } else {
            createShortcutOld(context, shortcutIntent, note);
        }
    } else {
        createShortcutOld(context, shortcutIntent, note);
    }
}

private static void createShortcutOld(Context context, Intent shortcutIntent, Note note) {
    Intent addIntent = new Intent();
    addIntent.putExtra(Intent.EXTRA_SHORTCUT_INTENT, shortcutIntent);
    addIntent.putExtra(Intent.EXTRA_SHORTCUT_NAME, note.getTitle());
    addIntent.putExtra(Intent.EXTRA_SHORTCUT_ICON_RESOURCE,
            Intent.ShortcutIconResource.fromContext(context, R.drawable.ic_launcher_round));
    addIntent.setAction("com.android.launcher.action.INSTALL_SHORTCUT");
    context.sendBroadcast(addIntent);
}
复制代码

对于第二种实现方式,咱们能够在 Manifest 文件中进行注册,并为其指定 ACTION 和启动类来实现各个选项被点击以后发送的事件。而后,咱们在指定的 Activity 中对各个 ACTION 进行处理便可,具体能够参考源代码。另外,这里的快速建立笔记仍是比较有意思的,能够打开一个背景透明的 Activity 并在其中弹出一个自定义对话框来快速编辑笔记。能够帮助咱们快速地记录本身的笔记。

2.4.2 指纹解锁

固然,这部分功能,咱们直接使用了一个开源的三方库。毕竟人家为还为各个系统的指纹解锁的支持作了处理,因此这里咱们直接奉行拿来主义了。这个项目的地址是:github.com/uccmawei/Fi….

2.4.3 打开网页的各类问题

打开网页固然不难实现,咱们使用一个自定义的 WebView 便可实现。不过,在这个项目的重构版本中,咱们采用了一个开源的库 AgentWeb,它能够知足咱们很是多场景的应用。

另外,由于在咱们的新的重构版本中,将支持包和 targetApi 都提高到了 28,因此出现了一个问题:使用 http 的网页没法打开。为了解决这个问题,咱们须要在 Manifest 文件中指定网络配置文件的地址:

android:networkSecurityConfig="@xml/network_security_config"
复制代码

而后,在该配置文件中指定咱们能够访问的 http 白名单:

<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
    <domain-config cleartextTrafficPermitted="true">
        <domain includeSubdomains="true">mikecrm.com</domain>
        <domain includeSubdomains="true">m.weibo.cn</domain>
    </domain-config>
</network-security-config>
复制代码

在这里咱们还发现了一个其余的问题:咱们打开网页的时候设置的 Weibo 的连接是 https 的,可是由于咱们在移动设备上面使用,因此又被重定向到了 http://m.weibo.cn,致使咱们的网页没法打开。解决的方式即按照上面那样,将重定向以后的地址添加到白名单之中便可。

2.4.4 其余

  1. 在新的版本中,为了帮助咱们进一步优化程序,咱们使用了友盟进行埋点。
  2. 不注册支付宝和微信支付帐号进行打赏;
  3. 分享相关的逻辑等;
  4. 其余:新版本中咱们还增长了许多其余的逻辑,若是你感兴趣的话能够查看下代码。

三、总结

上面咱们介绍了项目的一些内容和新版本重构时加入的新功能等。这些新加入的东西也算是这段时间以来学习成果的一个小集合。固然,由于毕竟业余时间有限,代码中可能仍然存在一些不足和设计不良的地方,若是你发现了这些不愉快的问题,能够在 Github 上面为项目提 issue,很乐意与你沟通和学习!

最后,重申一下项目相关的连接:

  1. 酷安网下载连接:https://www.coolapk.com/apk/178276
  2. Google Play Store 下载:https://play.google.com/store/apps/details?id=me.shouheng.notepal
  3. Github 项目连接:https://github.com/Shouheng88/MarkNote

若是您喜欢个人文章,能够在如下平台关注我:

更多文章:Gihub: Android-notes

相关文章
相关标签/搜索