承上啓下:重構 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

相關文章
相關標籤/搜索