18
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

#学生LTAdvent Calendar 2017

Day 2

かっこよく目を引くアプリを作る -DirectCompositionで光るウィンドウ-

Posted at

以前さらっと書いたNeonFrame(https://github.com/Pctg-x8/NeonFrame )の焼きまわしというか詳細解説です。前半はDirectCompositionとWindowsの半透明ウィンドウの歴史についてさらっと触れて、後半ではこんな感じのものを作る予定です。
2017-12-01 (2).png
ウィンドウ上部がわかりやすいかと思いますが枠線がうっすらと光っています。これを作ります。

DirectCompositionについて

DirectCompositionとは、先頭にDirectとついているとおりDirectXに関係したWindows 8(7 SP1)以降で利用できるAPI群です。これを使うことにより、GPUを利用してビットマップの合成処理を高速に行うことが可能となります。また、合成をするだけでなく、同時に回転や拡大などの処理も行うことが可能で、半透明合成ももちろん可能で、うまく使えばゲームのHUDオーバーレイをこれだけで作ることも可能です。Windows 10になってから同時にブラーやアフィン変形などといったビットマップエフェクトを同時に掛けることも可能になって表現力が上がりました。
DirectCompositionが強力である所以はビットマップの合成にとどまらず、高品質なアニメーション機能を有することにあります。DirectCompositionを使用することで、ネイティブアプリでもCSS3やXAMLで実現できるような高品質かつ複雑なアニメーションやトランジションを、タイマーや割り込みの管理など煩わしい手順を経ることなく実装することが可能です。
DirectCompositionはいわゆるRetained ModeのAPIであり、メインループなどで毎回関数を呼び出すなどといった処理は必要なく大変扱いやすくなっています。

DirectComposition以前の特殊ウィンドウ

DirectCompositionが登場してトップレベルウィンドウで半透明合成が可能になりましたが、以前はこういったウィンドウは作れなかったのかというと別にそういうわけでもありません。写真は矩形でしたが、このセクションでは少し拡大して非矩形ウィンドウについて歴史を見ていきます。

古くはWindows95、SetWindowRgnという関数がありました。これはウィンドウにリージョンと呼ばれる、ある図形を表すオブジェクト(今で言うとsvgが近い存在かもしれません)を設定することでその範囲だけ見えるウィンドウにするといったものです。単純にクリッピングしているだけなので大昔のPC程度のスペックでもそこそこ動かせた半面、アンチエイリアスをはじめとする半透明の表現はできませんでした。

Windows2000になってから本格的に半透明なウィンドウのサポートが始まりました。公式にはレイヤードウィンドウと呼ばれています1。Windows2000およびXPではマウスカーソルに影がついていますが、あれがレイヤードウィンドウを応用したものとなっています。レイヤードウィンドウを取り扱うAPIは大きく二つあって、ひとつはSetLayeredWindowAttributes、もう一つはUpdateLayeredWindowです。前者は扱いやすい反面、カラーキー(透明色)かウィンドウ全体の透明度の設定しかできません。後者は透明度情報(アルファチャンネル)を持つDIBオブジェクトを作る必要があるものの、ピクセル単位での半透明合成を指定できるのでかなり近代的なウィンドウを作ることが可能です。

Windows Vistaではついにシステムにおけるウィンドウの合成処理がDirectX(GPU)ベースになりました2。これにより、ウィンドウフレームのガラス効果を高パフォーマンスで実現するなどといったことが可能になり見た目におけるUXが格段に向上しました。
Vistaにおけるもう一つ重要な導入がWPF(Windows Presentation Foundation)です。現在のWindows Store Appの礎であるものですが、内部の描画処理がGDIではなくDirectXによるものとなっています。といってもVista以降ではGDIもDirectXを経由するようになったので全部DirectXで書かれているものといっても変わらないのですが。というのも、Vista以降でウィンドウの合成を行うDWMがGPUベースの処理となっているため、各アプリケーションもウィンドウの内容をVRAMに置かなければならず、つまりDirectXでの描画がほぼ必須となっているためです。

さて、ウィンドウの合成がDWMによって一元管理されることとなりました。ここでDWMによる合成機能の一部をAPIとしてくくりだしたもの、それがDirectCompositionです。

DirectCompositionとUpdateLayeredWindow、欠点と利点

UpdateLayeredWindowでもピクセル単位の半透明合成は行えるのですが、DirectCompositionを使ったほうがずっと軽量です。というのはDirectCompositionがDWMに対してネイティブだから、で説明がつくと思います(他にも理由はあります)。また、UpdateLayeredWindowではピクセル情報のみを渡すのに対して、DirectCompositionはビットマップをどこに配置して、どのくらい拡大して、みたいな細かい情報も渡せるため柔軟です。
ただしDirectCompositionにも欠点があって、それは「透明部分のヒットテストが透過しない」ことです。UpdateLayeredWindowによる半透明合成はウィンドウのヒットテストにも影響を及ぼし、つまり完全に透明と指定した部分はマウスでクリックしたとしても後ろにあるウィンドウにすり抜けます。DirectCompositionでは透明部分をクリックしてもテストがすり抜けません。これは機能面では明らかに欠点ではあるのですが、パフォーマンスの面では利点となります。UpdateLayeredWindowの場合はヒットテストを行うピクセルが透明であるかどうかの情報が必要となりますが、そのためにはウィンドウのピクセルの内容をメインメモリにコピーしてくる必要があります。ご存知の通りGPUとCPU間のデータ転送はかなり遅いため、UpdateLayeredWindowを使った合成は重くなります。DirectCompositionは合成する内容を指定したらあとはDWM/GPUに任せっぱなしにできるのでかなり軽量です。

Windowsの最高のデスクトップAPIを駆使してあまり手間をかけずに光るウィンドウを作ってみた

たぶん手間はかかってないはず。
環境はWindows 10 build 15063.726、言語はC++です。
メインはDirectCompositionなのでそれ以外の部分は飛ばし飛ばしで解説していきます。まずはコンポジションエンジンの初期化部分から

main.cpp
#include <windows.h>
#include <windowsx.h>
#include <stdexcept>
#include <memory>

#include <d3d11.h>
#include <d2d1_1.h>
#include <dcomp.h>
#pragma comment(lib, "d3d11")
#pragma comment(lib, "d2d1")
#pragma comment(lib, "dcomp")

#include <comdef.h>
#include <wrl.h>
template<typename T> using ComPtr = Microsoft::WRL::ComPtr<T>;

class RenderDevice final
{
	ComPtr<ID3D11Device> d3;
	ComPtr<ID2D1Device> d2;
public:
	RenderDevice()
	{
		auto hr = D3D11CreateDevice(nullptr, D3D_DRIVER_TYPE_HARDWARE, nullptr, D3D11_CREATE_DEVICE_BGRA_SUPPORT, nullptr, 0, D3D11_SDK_VERSION,
			&this->d3, nullptr, nullptr);
		if (FAILED(hr)) throw _com_error(hr);
		ComPtr<IDXGIDevice> pdx; hr = this->d3.As(&pdx);
		if (FAILED(hr)) throw _com_error(hr);
		const auto cp = D2D1::CreationProperties(D2D1_THREADING_MODE_SINGLE_THREADED, D2D1_DEBUG_LEVEL_ERROR, D2D1_DEVICE_CONTEXT_OPTIONS_ENABLE_MULTITHREADED_OPTIMIZATIONS);
		hr = D2D1CreateDevice(pdx.Get(), cp, &this->d2);
		if (FAILED(hr)) throw _com_error(hr);
	}
	auto& device2() const { return this->d2; }
};
class Composition final
{
	ComPtr<IDCompositionDesktopDevice> d;
	ComPtr<IDCompositionDevice3> fx_factory;
public:
	Composition(const RenderDevice& rd)
	{
		auto hr = DCompositionCreateDevice3(rd.device2().Get(), __uuidof(*this->d.Get()), &this->d);
		if (FAILED(hr)) throw _com_error(hr);
		hr = this->d.As(&this->fx_factory);
		if (FAILED(hr)) throw _com_error(hr);
	}

	auto& device() const { return this->d; }
	auto& effect() const { return this->fx_factory; }
};

先頭のincludeはWindowsとDirectX、それからunique_ptrstd::exceptionを使うためのものとなります。DirectXを使っている以上クロスプラットフォームなコードにはできないし、他に組み込まれることもないただのアプリケーションコードなのでガンガン例外を使う方針でいきます。
頭のほうにあるincludeからusingまでの3行はCOM用のスマートポインタとCOMの例外クラスを使うためのものです。ComPtr(=Microsoft::WRL::ComPtr)_com_errorが利用できるようになります。
DirectCompositionの初期化はCompositionクラスだけです。DirectCompositionではDCompositionCreateDevice3を使ってデバイスを初期化します。ほかに無印と2もあるのですが、無印はまず使わないのと、今回はエフェクトも利用したいので最新バージョンの3を使用しています。バージョン3はWindows 10 Anniversary Update(build 14393)以降で利用できます。
インターフェイスの受け取りにはIDCompositionDesktopDeviceを使用します。そののち、IDCompositionDevice3インターフェイスを取得します。前者はデスクトップにおけるコンポジションに必要なオブジェクトの生成を、後者はバージョン3で新規に追加されたエフェクトオブジェクトの生成を担います。
RenderDeviceクラスは何をしているかというと、描画用のDirect2Dデバイスを初期化しています。DirectCompositionはビットマップの合成のみを行うので描画は別の機構が必要になります。Direct2Dを利用してDirectCompositionが用意するサーフェイスに描くためにはID2D1Deviceが必要になるのですが、それを作るにはIDXGIDeviceが必要、つまりDXGIが初期化済みである必要があります。DXGIを直接初期化する方法はないのでDirect3Dを経由して作ります。

つづいてウィンドウの外見に関するクラスです。

main.cpp
const unsigned Margin = 8;

class AppWindowAppearance final
{
	ComPtr<IDCompositionTarget> target;
	ComPtr<IDCompositionScaleTransform> backface_scaler;
	ComPtr<IDCompositionVirtualSurface> border_surface;
public:
	AppWindowAppearance(const Composition& comp, HWND target)
	{
		HRESULT hr;
		ComPtr<IDCompositionVisual2> vroot, vbf, vbb, vbb_glow2, vbb_glow4;
		ComPtr<IDCompositionSurface> sbf;
		ComPtr<IDCompositionGaussianBlurEffect> blur2, blur4;
		ComPtr<IDCompositionBlendEffect> ablend2, ablend4;

		// construct a composition target and the base visual
		hr = comp.device()->CreateTargetForHwnd(target, false, &this->target); if (FAILED(hr)) throw _com_error(hr);
		hr = comp.device()->CreateVisual(&vroot); if (FAILED(hr)) throw _com_error(hr);
		hr = this->target->SetRoot(vroot.Get()); if (FAILED(hr)) throw _com_error(hr);

		// construct backface
		hr = comp.device()->CreateVisual(&vbf); if (FAILED(hr)) throw _com_error(hr);
		hr = comp.device()->CreateSurface(1, 1, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_ALPHA_MODE_PREMULTIPLIED, &sbf); if (FAILED(hr)) throw _com_error(hr);
		hr = vbf->SetContent(sbf.Get()); if (FAILED(hr)) throw _com_error(hr);
		hr = comp.device()->CreateScaleTransform(&this->backface_scaler); if (FAILED(hr)) throw _com_error(hr);
		hr = vbf->SetTransform(this->backface_scaler.Get()); if (FAILED(hr)) throw _com_error(hr);

		// construct border
		hr = comp.device()->CreateVisual(&vbb); if (FAILED(hr)) throw _com_error(hr);
		hr = comp.device()->CreateVisual(&vbb_glow2); if (FAILED(hr)) throw _com_error(hr);
		hr = comp.device()->CreateVisual(&vbb_glow4); if (FAILED(hr)) throw _com_error(hr);
		hr = comp.device()->CreateVirtualSurface(128, 128, DXGI_FORMAT_B8G8R8A8_UNORM, DXGI_ALPHA_MODE_PREMULTIPLIED, &this->border_surface); if (FAILED(hr)) throw _com_error(hr);
		hr = vbb->SetContent(this->border_surface.Get()); if (FAILED(hr)) throw _com_error(hr);
		hr = vbb_glow2->SetContent(this->border_surface.Get()); if (FAILED(hr)) throw _com_error(hr);
		hr = vbb_glow4->SetContent(this->border_surface.Get()); if (FAILED(hr)) throw _com_error(hr);

		// construct effects
		hr = comp.effect()->CreateGaussianBlurEffect(&blur2); if (FAILED(hr)) throw _com_error(hr);
		hr = comp.effect()->CreateGaussianBlurEffect(&blur4); if (FAILED(hr)) throw _com_error(hr);
		hr = comp.effect()->CreateBlendEffect(&ablend2); if (FAILED(hr)) throw _com_error(hr);
		hr = comp.effect()->CreateBlendEffect(&ablend4); if (FAILED(hr)) throw _com_error(hr);
		hr = blur2->SetStandardDeviation(2.0f); if (FAILED(hr)) throw _com_error(hr);
		hr = blur4->SetStandardDeviation(4.0f); if (FAILED(hr)) throw _com_error(hr);
		hr = ablend2->SetMode(D2D1_BLEND_MODE_LINEAR_BURN); if (FAILED(hr)) throw _com_error(hr);
		hr = ablend4->SetMode(D2D1_BLEND_MODE_LINEAR_BURN); if (FAILED(hr)) throw _com_error(hr);
		hr = ablend2->SetInput(0, blur2.Get(), 0); if (FAILED(hr)) throw _com_error(hr);
		hr = ablend4->SetInput(0, blur4.Get(), 0); if (FAILED(hr)) throw _com_error(hr);
		hr = vbb_glow2->SetEffect(blur2.Get()); if (FAILED(hr)) throw _com_error(hr);
		hr = vbb_glow4->SetEffect(blur4.Get()); if (FAILED(hr)) throw _com_error(hr);

		// construct visual tree
		hr = vroot->AddVisual(vbf.Get(), false, nullptr); if (FAILED(hr)) throw _com_error(hr);
		hr = vroot->AddVisual(vbb.Get(), false, nullptr); if (FAILED(hr)) throw _com_error(hr);
		hr = vroot->AddVisual(vbb_glow2.Get(), false, nullptr); if (FAILED(hr)) throw _com_error(hr);
		hr = vroot->AddVisual(vbb_glow4.Get(), false, nullptr); if (FAILED(hr)) throw _com_error(hr);
		hr = vbf->SetOffsetX(Margin); if (SUCCEEDED(hr)) hr = vbf->SetOffsetY(Margin); if (FAILED(hr)) throw _com_error(hr);
		hr = vbb->SetOffsetX(Margin); if (SUCCEEDED(hr)) hr = vbb->SetOffsetY(Margin); if (FAILED(hr)) throw _com_error(hr);
		hr = vbb_glow2->SetOffsetX(Margin); if (SUCCEEDED(hr)) hr = vbb_glow2->SetOffsetY(Margin); if (FAILED(hr)) throw _com_error(hr);
		hr = vbb_glow4->SetOffsetX(Margin); if (SUCCEEDED(hr)) hr = vbb_glow4->SetOffsetY(Margin); if (FAILED(hr)) throw _com_error(hr);

		// initialize backface pixels
		ComPtr<ID2D1DeviceContext> c; POINT o;
		hr = sbf->BeginDraw(nullptr, IID_ID2D1DeviceContext, &c, &o); if (FAILED(hr)) throw _com_error(hr);
		c->Clear(D2D1::ColorF(0x99ccff, 0.25f));
		hr = sbf->EndDraw(); if (FAILED(hr)) throw _com_error(hr);

		hr = comp.device()->Commit(); if (FAILED(hr)) throw _com_error(hr);
	}

	void resize(const SIZE& size)
	{
		HRESULT hr;

		SIZE sz = { size.cx - Margin * 2, size.cy - Margin * 2 };
		hr = this->backface_scaler->SetScaleX(sz.cx); if (FAILED(hr)) throw _com_error(hr);
		hr = this->backface_scaler->SetScaleY(sz.cy); if (FAILED(hr)) throw _com_error(hr);
		hr = this->border_surface->Resize(sz.cx, sz.cy); if (FAILED(hr)) throw _com_error(hr);

		// update border pixels
		ComPtr<ID2D1DeviceContext> c; POINT o;
		ComPtr<ID2D1SolidColorBrush> b;
		hr = this->border_surface->BeginDraw(nullptr, IID_ID2D1DeviceContext, &c, &o); if (FAILED(hr)) throw _com_error(hr);
		c->Clear(D2D1::ColorF(0x00, 0.0f));
		hr = c->CreateSolidColorBrush(D2D1::ColorF(0x66aaff), &b); if (FAILED(hr)) throw _com_error(hr);
		c->DrawRectangle(D2D1::RectF(o.x, o.y, o.x + sz.cx, o.y + sz.cy), b.Get(), 4.0f);
		hr = this->border_surface->EndDraw(); if (FAILED(hr)) throw _com_error(hr);
	}
};

コンポジションには、最低でも「どこにどのビジュアルをコンポジションするのかを表すIDCompositionTarget」「一番基礎となるIDCompositionVisual2」が必要となります。ビジュアルとはサーフェイス(ビットマップ)をどのように合成するかを表すオブジェクトで、ツリーを構成します。子となるビジュアルは親のビジュアルのオフセットやその他変形情報に影響されます。
construct a composition target and the base visualの部分で生成とルートの登録を行っています。今回は背景のビジュアルを別途伸縮したいので別途用意していますが、ルートのビジュアルがサーフェイスを持っていてもかまいません。
背景は今回は単色なので、1pxのサーフェイスを作ってそれを拡大する実装にしています。後述するIDCompositionVirtualSurfaceで毎回描き直してもいいですが、描き直す分のコストがかかるのと、VirtualSurfaceはタイルベースのサーフェイスでVRAM上で必ずしも連続であるとは限らないので、要するにキャッシュ効率が悪化します。またタイルの新規割り当てにもコストがかかるので、VirtualSurfaceの利用はそこそこ慎重であるべきです。
枠線は一つのサーフェイスを複数のビジュアル(通常、グロー、グロー+)で共有しています。コンポジション時にエフェクトをかけられるので、あらかじめエフェクトをかけたサーフェイスが必要ないというとこでメモリの大幅な節約ができます。
エフェクトとしてはガウスブラーと加算合成を用意しています。エフェクトは一種のツリーを形成することが可能で、複数のエフェクトをまとめて掛けることが可能です。
最後にビジュアルをルートに追加して位置を設定しておしまいです。

リサイズでは、背景は単純に拡大率を変更するだけでいいとして枠線は新たに描き直す必要があります。IDCompositionSurface::BeginDrawからIDCompositionSurface::EndDrawまでがDirect2Dの描画コードです。ID2D1DeviceContext::EndDrawは呼び出す必要はありません。

最後にエントリポイントとAppクラスです。

main.cpp
class App final
{
	HWND w;
	std::unique_ptr<Composition> comp;
	std::unique_ptr<AppWindowAppearance> appearance;
	SIZE currentSize;

	void init()
	{
		WNDCLASSEX wce{};
		wce.cbSize = sizeof wce;
		wce.hInstance = GetModuleHandle(nullptr);
		wce.lpszClassName = "com.cterm2.NeonFrame2.app";
		wce.lpfnWndProc = App::WndProc;
		wce.style = CS_OWNDC;
		if (RegisterClassEx(&wce) == 0) throw std::exception("RegisterClassEx failed");

		this->w = CreateWindowEx(WS_EX_APPWINDOW | WS_EX_NOREDIRECTIONBITMAP, wce.lpszClassName, "NeonFrame",
			WS_OVERLAPPEDWINDOW, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, CW_USEDEFAULT, nullptr, nullptr, wce.hInstance, nullptr);
		if (this->w == nullptr) throw std::exception("CreateWindowEx failed");
	}
	void resize(const SIZE& newsize)
	{
		this->currentSize = newsize;
		this->appearance->resize(newsize);
		auto hr = this->comp->device()->Commit(); if (FAILED(hr)) throw _com_error(hr);
	}
	LRESULT hittest(POINT p2)
	{
		MapWindowPoints(nullptr, this->w, &p2, 1);
		const auto left = 0 <= p2.x && p2.x <= Margin, top = 0 <= p2.y && p2.y <= Margin;
		const auto right = this->currentSize.cx - Margin <= p2.x && p2.x <= this->currentSize.cx;
		const auto bottom = this->currentSize.cy - Margin <= p2.y && p2.y <= this->currentSize.cy;

		if (top) return left ? HTTOPLEFT : right ? HTTOPRIGHT : HTTOP;
		if (bottom) return left ? HTBOTTOMLEFT : right ? HTBOTTOMRIGHT : HTBOTTOM;
		return left ? HTLEFT : right ? HTRIGHT : HTCAPTION;
	}
	static LRESULT CALLBACK WndProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
	{
		switch (msg)
		{
		case WM_DESTROY: PostQuitMessage(0); return 0;
		case WM_SIZE: instance().resize({ LOWORD(lp), HIWORD(lp) }); break;
		case WM_NCCALCSIZE: return 0;
		case WM_NCHITTEST: return instance().hittest({ GET_X_LPARAM(lp), GET_Y_LPARAM(lp) });
		}
		return DefWindowProc(w, msg, wp, lp);
	}
public:
	static App& instance() { static App o; return o; }

	int run()
	{
		this->init();

		auto rd = RenderDevice();
		this->comp = std::make_unique<Composition>(rd);
		this->appearance = std::make_unique<AppWindowAppearance>(*this->comp, this->w);

		ShowWindow(this->w, SW_SHOWNORMAL);
		MSG msg;
		while (GetMessage(&msg, nullptr, 0, 0) > 0) { TranslateMessage(&msg); DispatchMessage(&msg); }
		return msg.wParam;
	}
};
int WINAPI WinMain(HINSTANCE, HINSTANCE, LPSTR, int) { return App::instance().run(); }

WM_NCCALCSIZEで何もせずに帰ることでウィンドウフレームを消しています。また、WM_NCHITTESTおよびApp::hittestで非クライアント領域のヒットテストを上書きしています。コンポジション側でウィンドウの周囲をMarginpxずつ開けるようにしてあり、そこでウィンドウサイズを変更できるようにしています。
DirectCompositionにおいて各種パラメータを変更したあとはIDCompositionDevice::Commitを呼び出す必要があります。これによって加えられた変更がすべて適用されます。

ここで示したコードは解説用にベタめに書いているので少し汚いですが、本番ではCOMのエラー処理などを極力隠蔽した専用クラスを用意することをお勧めします。

おまけ: ウィンドウの背景をぼかす

Windows 10の隠しAPIです。詳しいことはRafael Rivera氏の記事(https://withinrafael.com/2015/07/08/adding-the-aero-glass-blur-to-your-windows-10-apps/ )を見てもらうことにして、とりあえず次のコードを追加することで動きます。

main.cpp
// AppWindowAppearanceに追加
void enable_behind_blur(HWND target)
{
    struct AccentPolicy { DWORD state; LONG flags, gradient, animationid; };
    struct WINCOMPATTRDATA { DWORD attribute; PVOID pData; ULONG dataSize; };
    auto lib = LoadLibrary("user32.dll");
    auto fptr = reinterpret_cast<BOOL(WINAPI *)(HWND, WINCOMPATTRDATA*)>(GetProcAddress(lib, "SetWindowCompositionAttribute"));
    if (fptr == nullptr)
    {
        FreeLibrary(lib);
        throw std::exception("SetWindowCompositionAttribute was not found.");
    }

    // ACCENT_ENABLE_BLURBEHIND = 3
    AccentPolicy policy = { 3, 0, 0, 0 };
    // ACCENT_POLICY = 19
    WINCOMPATTRDATA params = { 19, reinterpret_cast<PVOID>(&policy), sizeof AccentPolicy };
    fptr(target, &params);

    FreeLibrary(lib);
}

// ShowWindow前に追加
this->appearance->enable_behind_blur(this->w);

ただし、微妙に残念な結果になります。
2017-12-01 (3).png
上のほうにある2つのアイコンを見ていただければわかると思いますが、思っていたより外側にまでブラーが掛かってしまっています。まあこのエリアも同じウィンドウなので致し方ないのですが。これを解決するにはグロー部分を別のウィンドウにする以外に方法はないと思います。

出典など

  1. https://msdn.microsoft.com/ja-jp/library/ms997507.aspx

  2. https://msdn.microsoft.com/en-us/library/windows/desktop/aa969540(v=vs.85).aspx

18
13
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
13

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?