LoginSignup
381
324

More than 3 years have passed since last update.

Flutterで1年間開発して利用した使えるライブラリ

Last updated at Posted at 2018-12-21

Flutter本体の記事は増えてきましが、どんなライブラリがあるのかまたその使い勝手なども重要です。
特にFlutterはバージョン1が出たばかりで、仕事で利用するのに不安があるはずです。

Flutter本体はベータ版の頃、いやそれ以前から、とてもよく出来ていました。
じゃあ周辺のライブラリはどうなの? どこまで用意されてるの? と知りたい方向けです。

この記事では実際に1年間仕事で利用しているライブラリの所感を記述していきます。

できる限り最新情報に更新していきます。
2019/07/30の最新バージョンを記述しています。
これらは全てAndroidX対応されたライブラリになっています。

その前に

Flutter(dart)では全てのライブラリは、ここにあると考えて良いです。
Javaで言う Maven Repository、 iosでいうところの CocoaPods Master Repo といったところでしょうか。
Flutterも他と同様にgithubだけで管理されているライブラリも指定できますが、2018年末の時点ではあまりないでしょう。

以下のように記述して、ライブラリを使います。

  • https://pub.dartlang.org/ から取得 (sample_plugin0)
  • ローカルのpathを指定 (sample_plugin1)
  • githubでルートにライブラリがある場合(sample_plugin2)
  • githubでdirectoryの中にある場合の取得(sample_package1)
pubspec.yaml
dependencies:
  sample_plugin0: ^1.0.0
  sample_plugin1:
    path: ../sample_plugin1/
  sample_plugin2:
    git:
      url: git://github.com/flutter/sample_plugin2.git
      ref: sample_branch
  sample_package1:
    git:
      url: git://github.com/flutter/packages.git
      path: packages/sample_package1  

端末情報系

device_info: 0.4.0+2

それぞれのデバイス情報を取得するライブラリです。

微妙なところは、ios、androidとともに別々の変数に格納されていることでしょうか。
ただ、ios,androidともに取得できる情報が違うので仕方ない気もします。

以下のようにdart側でios or androidの分岐コードを書く必要があります。

  DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();

  if (Theme.of(context).platform == TargetPlatform.android) {
    deviceInfo.androidInfo.then((AndroidDeviceInfo info){
      print("baseOS=${info.version.baseOS}"); // otorola/cedric/cedric:8.1.0/OPQ28.85-13/05b9:user/release-keys
      print("codename=${info.version.codename}"); // REL
      print("incremental=${info.version.incremental}"); // fda17
      print("previewSdkInt=${info.version.previewSdkInt}"); // 0
      print("release=${info.version.release}"); // 8.1.0
      print("sdkInt=${info.version.sdkInt}"); // 27
      print("securityPatch=${info.version.securityPatch}"); // 2018-10-01

      print("board=${info.board}"); // msm8937
      print("bootloader=${info.bootloader}"); // 0xB831
      print("brand=${info.brand}"); // motorola
      print("device=${info.device}"); // cedric
      print("display=${info.display}"); // OPQS28.85-13-2
      print("fingerprint=${info.fingerprint}"); // motorola/cedric/cedric:8.1.0/OPQS28.85-13-2/fdc25:user/release-keys
      print("hardware=${info.hardware}"); // qcom
      print("host=${info.host}"); // ilclbld107
      print("id=${info.id}"); // OPQS28.85-13-2
      print("manufacturer=${info.manufacturer}"); // motorola
      print("model=${info.model}"); // Moto G (5)
      print("product=${info.product}"); // cedric
      print("supported32BitAbis=${info.supported32BitAbis}"); // [armeabi-v7a, armeabi]
      print("supported64BitAbis=${info.supported64BitAbis}"); // []
      print("supportedAbis=${info.supportedAbis}"); // [armeabi-v7a, armeabi]
      print("tags=${info.tags}"); // release-keys
      print("type=${info.type}"); // user 
      print("isPhysicalDevice=${info.isPhysicalDevice}"); // true
      print("androidId=${androidDeviceInfo.androidId}");
    });
  } else {
    deviceInfo.iosInfo.then((IosDeviceInfo info){
      print("name=${info.name}"); // ko2ic の iPhone
      print("systemName=${info.systemName}"); // iOS
      print("systemVersion=${info.systemVersion}"); // 12.1
      print("model=${info.model}"); // iPhone
      print("localizedModel=${info.localizedModel}"); // iPhone
      print("identifierForVendor=${info.identifierForVendor}"); // D644A484-CE48-47EE-8A2F-30488E0D71E1
      print("isPhysicalDevice=${info.isPhysicalDevice}"); // false
      print("sysname=${info.utsname.sysname}"); // Darwin
      print("nodename=${info.utsname.nodename}"); // ko2ic-no-iPhone
      print("release=${info.utsname.release}"); // 18.2.0
      print("version=${info.utsname.version}"); // Darwin Kernel Version 18.2.0: Mon Nov 12 20:24:46 PST 2018; root:xnu-4903.231.4~2/RELEASE_X86_64
      print("machine=${info.utsname.machine}"); // x86_64
    });
}

コメントは、こんな感じの結果が返るという例です。

結論

使えます。

package_info: 0.4.0+6

パッケージ情報を取得できます。

    PackageInfo.fromPlatform().then((PackageInfo info){
      print(info.appName);
      print(info.packageName);
      print(info.version);
      print(info.buildNumber);
    });

結論

使えます。

画像系

image_picker: 0.6.0+20

端末に保存されている画像を選択してdartで扱えるライブラリです。
これだけで動作します。

var image = await ImagePicker.pickImage(source: ImageSource.gallery);

ImageSourceには gallery と カメラを起動してそれを利用する camera があります。

ただし、このライブラリは正直いけてなくて、iosでは、exif情報などが失われます。
exif情報が失われないようなプルリクエストを10/23に送りましたが、いまだにマージされていません。
(2019/05現在、ようやく有用さに気づいたReviewerが現れてくれました。
正直、自分で全部やりたかったけど、このReviewerに引き継いだ方が早そうなので、引き継ぎました。早くマージしてくれ。)
https://github.com/flutter/plugins/pull/866

2019/5/23 追加
ついにexif問題対応してくれました。
Googleの人に引き継いだら、自分が書いたよりもいけてるコードになってて、さすがだと思いました。
2019/5/23 追加ここまで

さらに、このライブラリは一つの画像しか選択できません。
複数選択できるライブラリもありますが、2018年末時点では良いのが見当たりません。

結論

アニメーションGIFが対応されていませんが、使えます。

image_downloader: 0.16.2

インターネットにある画像をAndroidならダウンロードディレクトリ、iosならPhoto Libraryに保存するライブラリです。必要だったので作りました。

以下、例です。

try {
  // Saved with this method.
  var imageId = await ImageDownloader.downloadImage("https://raw.githubusercontent.com/wiki/ko2ic/image_downloader/images/flutter.png");
  if (imageId == null) {
    return;
  }

  // Below is a method of obtaining saved image information.
  var fileName = await ImageDownloader.findName(imageId);
  var path = await ImageDownloader.findPath(imageId);
  var size = await ImageDownloader.findByteSize(imageId);
  var mimeType = await ImageDownloader.findMimeType(imageId);
} on PlatformException catch (error) {
  print(error)
}

成功したら、imageIdが返ります。このimageIdを使って保存先のファイル名などを取得できます。
ユーザが権限をdenyしたらnull。
それ以外はPlatformExceptionが返ります。

結論

使って欲しいし、駄目だったらプルリクエストやisuueをあげて頂けると嬉しいです。

photo_view: 0.4.2

画像をピンチイン・ピンチアウトで拡大縮小などができるライブラリです。
複数画像を同時表示もできます。

@override
Widget build(BuildContext context) {
  return Container(
    child: PhotoView(
      imageProvider:  NetworkImage('https://sample.com/image.jpeg')),
    )
  );
}

結論

使えます。

最新のflutterではエラーになるので、以下のブランチを使うといいでしょう。

  photo_view: 
    git:
      url: git@github.com:robinbonnes/photo_view.git
      ref: patch-1

image_cropper: 1.0.2

ユーザアイコンなどの設定で、画像をくり抜くためのライブラリです。
丸くも切り取れます。

結論

使えます。

端末機能系

share: 0.6.2+1

AndroidではACTION_SENDインテント、iosではUIActivityViewControllerを使った共有をするライブラリです。

これだけで使えます。

Share.share('check out my website https://example.com');

結論

使えます。

connectivity: 0.4.3+6

wifi接続なのかどうか、などの接続に関するライブラリです。

      var connectivityResult = await Connectivity().checkConnectivity();
      if (connectivityResult != ConnectivityResult.wifi) {
        return Future.value(false);
      }

ネットワークの変化も感知できます。

initState() {
  subscription = new Connectivity().onConnectivityChanged.listen((ConnectivityResult result) {
    // Got a new connectivity status!
  })
}

結論

使えます。

url_launcher: 5.1.1

androidではurlをブラウザで、iosではwebviewで表示するライブラリです。

http,https,mailto,smsをサポートしているようです。

_launchURL() async {
  const url = '';
  if (await canLaunch(url)) {
    await launch(url);
  } else {
    throw 'Could not launch $url';
  }
}

ちなみにiosのwebviewはこんな感じです。androidは、forceWebView: trueにすることでwebview表示にすることも可能です。

スクリーンショット 2018-12-17 23.38.51.png

結論

使えます。

qr_flutter: 2.1.0+55

QRコードを表示するライブラリです。

new QrImage(
  data: "https://flutter.io",
  size: 200.0,
),

結論

最新のflutterと互換性があるのが正式版になりました
最新のflutterと互換性があるのは以下のブランチです。こちらを使いましょう。

使えます。

広告系

firebase_admob: ^0.7.0

(利用していないのでこのバージョン以降は試していないです)

昔からあるadmobライブラリです。古いのでPlatformViewで実装されていません。

そのためあまり良くないです。 バナー広告は、上部か下部にしか配置できず、さらにz軸の一番上に配置されます。
以下のように、anchorOffset で表示位置を自分で指定する必要があります。
端末によって場所がずれるので分岐が必要になります。

 void initState() {
    super.initState();

    var adUnitId = BannerAd.testAdUnitId;
    if (Environment.isProduction) { // これは自作のメソッド
      if (Platform.isAndroid) {
        adUnitId = "ca-app-pub-xxxxxx/xxxxx";
      } else if (Platform.isIOS) {
        adUnitId = "ca-app-pub-yyyyy/yyyyyy";
      }
    }

    _bannerAd = BannerAd(
      adUnitId: adUnitId,
      size: AdSize.smartBanner,
      listener: (MobileAdEvent event) {
        // TODO
      },
    );

    double anchorOffset = 80.0;
    if (Platform.isIOS) {
      anchorOffset = 112;
    }

    _bannerAd
      ..load()
      ..show(
        anchorOffset: anchorOffset,
        anchorType: AnchorType.top,
      );
  }

  @override
  void dispose() {
    _bannerAd.dispose();
    super.dispose();
  }

結論

場合によっては使えますが、微妙です。
最初に書いたこと以外にも問題点があります。

一度表示するとこのpluginの dispose() メソッドが呼ばれるまで表示され続けます。
当然、次の画面に遷移しても表示されたままになります。
遷移する際に消したとしても、遷移のアニメーションとは別の次元にいるので消え方や表示され方が微妙です。
そして、disposeされたかどうかの判断はできません。さらに、二度disoposeするとクラッシュします。

非常に扱いづらいですが、どの画面でも同じ箇所に広告を表示するのであれば使えます。

admob_flutter: ^0.2.0

(メンテナーを探しているようで、今後は期待できないでしょう。)

こちらは、PlatformViewで実装されていて良いですが、androidしか対応されていません。
なので使ってないです。

iosも対応予定のようですが、まだ、プルリクエストを受け付けていないようで、気長に待つしかなさそうです。
いつまでも対応されなければ、別のライブラリを作ろうかとも少し思ってます。

結論

やめたほうがいいです。

flutter_google_ad_manager: 0.9.0

これも作りました。
旧 DoubleClick For Publishers(DFP)での広告を表示するためのライブラリです。

結論

普通に使えます。

クラッシュレポート関連

sentry: 2.2.0

料金はかかりますが、dart部分でしっかりとクラッシュ場所がわかります。


final SentryClient sentry = new SentryClient(dsn: YOUR_DSN);

void main(){
  runZoned<Future<void>>(() async {
    runApp(App(
      store: store,
    ));
  }, onError: (error, stackTrace) {
   
      SentryResponse response = await _captureException(level: sentryLevel, exception: error, stackTrace: stackTrace);
      if (response.isSuccessful) {
        Log.info('Success! Event ID: ${response.eventId}');
      } else {
        Log.info('Failed to report to Sentry.io: ${response.error}');
      }
  });

  FlutterError.onError = (FlutterErrorDetails details) {
    if (Environment.isDevelopment) {
      FlutterError.dumpErrorToConsole(details);
    } else {
      Zone.current.handleUncaughtError(details.exception, details.stack);
    }
  };

  Future<SentryResponse> _captureException({
    @required SeverityLevel level,
    @required dynamic exception,
    dynamic stackTrace,
  }) {
    final Event event = new Event(
      level: level,
      exception: exception,
      stackTrace: stackTrace,
    );
    return _sentry.capture(event: event);
  }
・・・

crashlyticsと違いデバイス情報は自動で取得できないので、自分でextrasとして設定したほうがいいでしょう。
ユーザ情報と同時に設定します。

    // deviceとuserEntityはオリジナルのクラスのインスタンスです。
    _deviceInfo = {
      "is_android": device?.isAndroid?.toString() ?? "unknown",
      "app_version": device?.appVersion ?? "unknown",
      "os_version": device?.osVersion ?? "unknown",
      "device_name": device?.deviceName ?? "unknown",
    };
     sentry.userContext = User(
      id: userEntity.id,
      username: userEntity.displayName,
      email: userEntity.email,
      ipAddress: "",
      extras: _deviceInfo,

こんな感じのレポートが出ます。stacktraceをちゃんと渡せば正確にエラーの場所を特定できます。
スクリーンショット 2018-12-21 19.04.28.png

結論

使えます。

flutter_crashlytics: 1.0.0

実装はsentryとほぼ同じです。違う点はデバイス情報は勝手に格納されるところです。
ユーザ情報は以下のようにします。

var _crashlytics = FlutterCrashlytics();
await _crashlytics.initialize();
await _crashlytics.setUserInfo(entity.id, entity.email, entity.displayName);

結論

Sentryで表示されているものよりcrashの数が少ないです。
なので、Sentryの方が良いのは間違い無いです。

ただ、予算をかけられないなら、これでいいでしょう。

firebase-crashlytics: 0.1.0+1

待望の公式のPluginです。

結論

だいぶ使えるようになりましたが、まだ、flutter_crashlyticsの方が良いです。

ユーティリティ関連

tuple: 1.0.2

dartではタプル型がないのでそれを実現するためのものです。

const t = const Tuple2<String, int>('a', 10);

print(t.item1); // prints 'a'
print(t.item2); // prints '10'

Tuple2,Tuple3といったように増えていきます。

結論

使えます。

uuid: 2.0.2

UUIDを生成します。

Uuid().v4();

結論

使えます。

mime: 0.9.6+2

mimetypeを扱うライブラリです。

以下のようにファイルの先頭のbyteからmimetypeを判断する時に利用しました。

    var bytes = await imageFile.openRead(0, 15).single;
    var mimeType = lookupMimeType(imageFile.path, headerBytes: bytes);

結論

使えます。

DB系

shared_preferences: 0.5.3+4

iosでいうUserDefaultsです。

以下、例です。

  Future<bool> firstRun() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();

    var firstRun = prefs.get(_key);

    if (firstRun == null) {
      return prefs.setBool(_key, true);
    } else {
      return Future.value(false);
    }
  }

インターフェイスは完全にandroidです。
ただし、commit()(iosのsynchronize()) はiosで非推奨なのでdartのメソッドも非推奨にしているようです。

結論

使えます。

sqflite: 1.1.6+3

sqliteを扱うライブラリです。

iosではFMDBを使っています。

結論

使えます。

firebase関連

firebase_auth: 0.14.0

使った機能は、login(Anonymous/google/facebook/twitter/カスタム), link, unlink, reauthenticate, delete、onAuthStateChanged、signOutなど、ほとんどの機能は利用しました。

結論

最近まで全然機能も足りなくて駄目でしたが、だんだん良くなってきました。
及第点だと思います。

0.7.0で大幅なインターフェイスの変更がありました。
以下例です。

  final FirebaseAuth _auth;
  final GoogleSignIn _google;
・・・
    final GoogleSignInAccount googleUser = await _google.signIn().catchError((error) {
      return null;
    }, test: (error) => error is PlatformException && (error as PlatformException).code == "sign_in_failed");
    if (googleUser == null) {
      // キャンセルの場合にここを通る
      return null;
    }
    final GoogleSignInAuthentication googleAuth = await googleUser.authentication;
    final AuthCredential credential = getGoogleCredential(
      idToken: googleAuth.idToken,
      accessToken: googleAuth.accessToken,
    );
    FirebaseUser firebaseUser = await _auth.signInWithCredential(credential);

AuthCredentialが導入され、sign,linkなど似たインターフェイスでできるようになっています。

0.12.0で、サインインメソッドはFirebaseUserの代わりにAuthResultを返すようになりました。 AuthResultのユーザープロパティを使用してFirebaseUserを取得します。

0.14.0で、getIdToken()の戻り値がStringからIdTokenResult型に変わりました。

firebase_storage: 3.0.4

画像を置いたり、削除したり、メタデータにデータを入れたり出したりしました。
問題ありませんでした。

結論

使えます。

cloud_firestore: 0.12.9

0.9.0+2はiosで動作しないので注意が必要です。

検索、ページング、登録、更新、削除、もちろんトランザクションも使用しましたが、問題ありませんでした。

### 結論

使えます。

firebase_dynamic_links: 0.5.0

リンク起動以外にもリンクの作成もしました。
iosでたまにエラーになるバグがありました。マージ済みです。

0.3.0でシグネチャが変わりました。
パラメーターの domainuriPrefix になりました。
また、https:// を付ける必要があるので注意です。

0.5.0で、retrieveDynamicLink()メソッドが二つに分割されました。getInitialLink()とonLink()です。

### 結論

使えます。

firebase_analytics: 4.0.2

### 結論

使えます。

firebase_messaging: 5.1.2

### 結論

Androidでアプリがkill状態の時に、Dataだけの場合はPushが届かないという問題があります。
公式にも書いてありますが、これはサポートしていないようです。
必ずNotificationも含ませる運用ができるなら使えるでしょう。

また、iosとandroidで取得の仕方が違います。
例えば、以下をpushした場合、

    "to" : <トークン>
    "notification" : {
        "title" : "テストTitle",
        "body" : "テストBody"
    },
    "data" : {
        "group_id": "123456789",
        "click_action": "FLUTTER_NOTIFICATION_CLICK"
    }

androidの場合は、data から取得する必要があります。iosは不要です。
そのため、自分は、以下のように取得しています。

      onResume: (Map<String, dynamic> message) {
        var groupId = message['group_id'] ?? message['data']['group_id']
      },

cloud_functions: 0.4.1

東京リージョンを使うには0.1.1以上が必要です。
その際にインスタンスでシングルトンを使わないようにしましょう。

var cloudFunctions = CloudFunctions(region: "asia-northeast1");

call() メソッドしか利用していませんので、他の機能はわかりません。
当初は、PlatformExceptionがくる前にネイティブで落ちるバグがありましたが、直っています。

0.2.0でインターフェイスが変わりました。

メソッド、 call() の前に getHttpsCallable() を呼ぶ必要があります。
戻り値の HttpsCallable に timeoutを設定できるようになっています。

### 結論

使えます。

firebase_performance: 0.3.0+4

結論

flutterなので、画面別の遅いレンダリングはわかりませんが、それ以外は使えます。

認証関連

google_sign_in: 4.0.4

結論

使えます。

flutter_facebook_login: 2.0.0

Facebook Android SDK自体がAndroidXに対応していなく、何故かProguardを通すとRファイルが見つからないようになりました。
(Proguardの書き方は正しいです。何故、気づいたかというとAndroidXに対応していない他のライブラリでも同様のことが起きて、対応バージョンに変更したら動作したからです。)

結論

おそらく、現在、使えない状態と考えています。
Facebook Android SDKがAndroidXに移行すれば利用できます。
以前は動作していたので、flutter自体のバージョンとflutter_facebook_login自体のバージョンなどの組み合わせで動作するはずですが、色々試したが結局わかりませんでした。

flutter_twitter_login: 1.1.0

このバージョンでは、ios/Podfileで、use_frameworks!を消さないと動作しなかったと思います。
プルリクエストが出ていますが、まだマージされていません。

結論

以下のバージョンであれば使えます。

pubspec.yml
dependency_overrides:
  flutter_twitter_login: # TODO マージされたバージョンが出たら変更すること
    git:
      url: git@github.com:dlutton/flutter_twitter_login.git

通知

flutter_local_notifications

ローカル通知なライブラリです。

結論

他の通知系のライブラリ(例えば、firebase_messaging)を使っている場合に、ライブラリのiosのコードをいじらないといけません。
以下の部分を削除する必要があります。

UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
center.delegate = instance;

なので自分は利用していません。

国際化対応

intl_translation

以下のintlで利用するのに便利なようにソースを生成してくれるツールです。
二つのコマンドを叩いて生成します。

結論

androidやiosの対応に比べて面倒ですが、これしか方法がないようです。

intl

国際化対応のライブラリです。

結論

面倒ですが、使えます。

redux関連

この二つは、Reduxアーキテクチャでのflutterに詳しく書いてあります。

redux: 3.0.0

結論

使えます。

flutter_redux: 0.5.3

結論

特に困ったことはないです。使えます。

アイコン系

cupertino_icons: 0.1.2

ios系のアイコンです。以下です。

image.png

結論

使えます。

font_awesome_flutter: 8.4.0

FontAwesomeなアイコンです。

結論

使えます。

transparent_image: 1.0.0

単純な透明画像です。

これを使って以下のように書けば、loadが始まる前に透明画像が配置され、徐々にloadされた画像が表示されていく実装ができます。

ClipOval(
          child: FadeInImage.memoryNetwork(
          fadeInDuration: const Duration(milliseconds: 200),
          width: 40,
          height: 40,
          fit: BoxFit.cover,
          placeholder: kTransparentImage,
          image: imageUrl,
        ));

結論

使えます。

単体テスト系

mockito: 4.1.0

単体テストは、ほとんどのパターンを行いましたので、dartでの単体テストはほぼ理解しているつもりです。
こちらの記事を参照してください。
上記の記事は中途半端ですが、完全版にするように修正します。

結論

使えます。

mock_web_server: 4.0.0

結論

使えます。

Flutter Plugin全般についての所感

Googleの物なので当然といえば当然なのですが、Androidの開発者が多い気がします。
Androidではうまく動くのにiosではバグが出る事が良くあります。
インターフェイスもAndroidに合わせている場合が多いです。

特にObjective-Cを知らない人がPluginを実装していたり、レビューをしていたりと感じました。
例えば、NSDictionaryにはnilは入れられないですが、普通にnilがくるコードが書いてあったりします。
こんな感じです。

// error.userInfo[FIRFunctionsErrorDetailsKey]はnilがくる可能性がある
@{ @"details" : error.userInfo[FIRFunctionsErrorDetailsKey] };

これ、単純にnilになる可能性のあるロジックの直後に ?: [NSNull null] を書けばいいのですが、そのプルリクエストを送るとこれは採用されずに、わざわざ if文でnilかどうかを判定して、nilじゃない場合に処理をするみたいなプルリクエストが採用されていました。(この場合、detailsというキーも含めたくなかっただけかもしれません。)

こういう単純なバグは結構あって、少し動かせば気づくことが結構あるし、ソースを少し読めばすぐ気づくこともあります。
なので、Pluginを使うときは、早めにiosで試してみるのが良いかもしれません。

また、同じGoogleなのでfirebaseのpluginはしっかりできているだろうと思いがちですが、意外とそうでもありません。他のPluginよりもバグが多い印象です。(当然、機能も多いのでバグは出やすいだけかもしれないです)

と、ここまで少しマイナスな印象を書いてしまいましたが、Pluginもどんどん良くなってきています。
もう、仕事で使っても問題ないレベルまで来ていると思います。
たとえ、バグがあっても、今までのAndroidやios開発の知識があれば、簡単に修正可能なはずです。

端末ごとにこだわりたいアプリでなければ、Flutterはとてもお勧めできるクロスプラットフォーム開発環境です。
どんどんプロジェクトで採用していくことをお勧めします。本当に簡単で便利です。

381
324
1

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
381
324