6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Fletを触ってみる。

Last updated at Posted at 2024-02-19

はじめに

社内のハッカソンが開催されたので、そこでFletライブラリを触ってみました。
https://flet.dev

Fletとは?

Fletは、フロントエンド開発の経験がなくても、Web、デスクトップ、モバイルアプリを好みの言語で構築できるクロスプラットフォームフレームワークです。

特徴

GoogleのFlutterをベースに作られており、以下の6つの特徴があります。

  1. アイデアからアプリまでが迅速
    内部ツール、チーム用ダッシュボード、週末プロジェクト、データ入力フォーム、キオスクアプリ、高忠実度プロトタイプなど、Fletは迅速にインタラクティブなアプリを作成するのに適しています。

  2. シンプルなアーキテクチャ
    JavaScriptフロントエンド、REST APIバックエンド、データベース、キャッシュなどの複雑なアーキテクチャを必要とせず、Pythonだけでモノリス状のステートフルアプリを作成でき、マルチユーザー、リアルタイムのシングルページアプリケーション(SPA)を提供します。

  3. バッテリー同梱
    開発を始めるために必要なのはお気に入りのIDEまたはテキストエディターのみです。SDKや多数の依存関係、複雑なツールは必要ありません。Fletには、アセットホスティングとデスクトップクライアントを備えた内蔵Webサーバーがあります。

  4. Flutterによる強化
    Flet UIはFlutterを使って構築されており、プロフェッショナルな外観のアプリを任意のプラットフォームに提供できます。FletはFlutterモデルを単純化し、より小さな「ウィジェット」を即使用可能な「コントロール」に組み合わせ、命令型プログラミングモデルを採用しています。

  5. 多言語対応
    Fletは言語非依存なので、チームの誰もがお気に入りの言語でFletアプリを開発できます。現在はPythonがサポートされており、Go、C#などが次に追加される予定です。

  6. あらゆるデバイスへの配信
    FletアプリをWebアプリとしてデプロイし、ブラウザで表示することができます。Windows、macOS、Linux用のスタンドアロンデスクトップアプリとしてパッケージ化することもできます。モバイルではPWAとしてインストールするか、iOSとAndroid用のFletアプリで表示することができます

実際に触ってみる

今回はチュートリアルのソリティアをやってみます。
https://flet.dev/docs/tutorials/python-solitaire

環境について

MacBook Pro(2020)にPython 3.11.3を入れて試していきます。
ちなみにFletを動かすにはPython 3.8以降が必要だそうです

インストール

pip install flet

hello world

はろわの実行

import flet as ft


def main(page: ft.Page):
    page.add(ft.Text(value="Hello, world!"))


ft.app(target=main)

アプリが立ち上がって無事表示されました。
スクリーンショット 2024-01-26 11.24.15.png

ソリティア ゲームの概念実証アプリ

細かくやると長くなるのでここからは一気に進めていきます。
気になる方は公式をチェックしてください。

カードの生成とカードを置くスロットを作ってドラッグ&ドロップできるようにして概念実証を行います。

class Solitaire:
    def __init__(self):
        self.start_top = 0
        self.start_left = 0


def main(page: ft.Page):
    solitaire = Solitaire()

    def start_drag(e: ft.DragStartEvent):
        solitaire.start_top = e.control.top
        solitaire.start_left = e.control.left
        e.control.update()

    def drag(e: ft.DragUpdateEvent):
        e.control.top = max(0, e.control.top + e.delta_y)
        e.control.left = max(0, e.control.left + e.delta_x)
        e.control.update()

    def drop(e: ft.DragEndEvent):
        for slot in slots:
            if (abs(e.control.top - slot.top) < 20 and abs(e.control.left - slot.left) < 20):
                place(e.control, slot)
                e.control.update()
                return
        bounce_back(solitaire, e.control)
        e.control.update()

    def bounce_back(game, card):
        """カードをもとの位置に戻す処理"""
        card.top = game.start_top
        card.left = game.start_left
        page.update()

    def place(card, slot):
        """カードをスロットに置く処理"""
        card.top = slot.top
        card.left = slot.left
        page.update()

    # カード定義
    card1 = ft.GestureDetector(
        mouse_cursor=ft.MouseCursor.MOVE,
        drag_interval=5,
        on_pan_update=drag,
        on_pan_end=drop,
        left=0,
        top=0,
        content=ft.Container(bgcolor=ft.colors.GREEN, width=70, height=100),
    )
    card2 = ft.GestureDetector(
        mouse_cursor=ft.MouseCursor.MOVE,
        drag_interval=5,
        on_pan_start=start_drag,
        on_pan_update=drag,
        on_pan_end=drop,
        left=100,
        top=0,
        content=ft.Container(bgcolor=ft.colors.YELLOW, width=70, height=100),
    )

    # スロット定義
    slot0 = ft.Container(width=70, height=100, left=0, top=0, border=ft.border.all(1))
    slot1 = ft.Container(width=70, height=100, left=200, top=0, border=ft.border.all(1))
    slot2 = ft.Container(width=70, height=100, left=300, top=0, border=ft.border.all(1))
    slots = [slot0, slot1, slot2]
    controls = [slot0, slot1, slot2, card1, card2]
    page.add(ft.Stack(controls=controls, width=1000, height=500))


ft.app(target=main)

上記コードを実行すると以下のような動作になります。(画像は公式)

ソリティアの完成

ルールを追加するだけなので、一気に最後まで飛ばします。

完成コード(長いので折りたたみます。)

import flet as ft
import random


CARD_WIDTH = 70
CARD_HEIGTH = 100
DROP_PROXIMITY = 30
CARD_OFFSET = 20


class Card(ft.GestureDetector):
    def __init__(self, solitaire, suite, rank):
        super().__init__()
        self.mouse_cursor = ft.MouseCursor.MOVE
        self.drag_interval = 5
        self.on_pan_start = self.start_drag
        self.on_pan_update = self.drag
        self.on_pan_end = self.drop
        self.on_tap = self.click
        self.on_double_tap = self.doubleclick
        self.suite = suite
        self.rank = rank
        self.face_up = False
        self.top = None
        self.left = None
        self.solitaire = solitaire
        self.slot = None
        self.content = ft.Container(
            width=CARD_WIDTH,
            height=CARD_HEIGTH,
            border_radius=ft.border_radius.all(6),
            content=ft.Image(src='images/card_back.png'),
        )

    def turn_face_up(self):
        '''カードを表にする'''
        self.face_up = True
        self.content.content.src = f'images/{self.rank.name}_{self.suite.name}.svg'
        self.solitaire.update()

    def turn_face_down(self):
        '''カードを裏にする'''
        self.face_up = False
        self.content.content.src = 'images/card_back.png'
        self.solitaire.update()

    def move_on_top(self):
        '''カードの山をスタックの一番上に移動'''
        for card in self.get_draggable_pile():
            self.solitaire.controls.remove(card)
            self.solitaire.controls.append(card)
        self.solitaire.update()

    def bounce_back(self):
        '''移動中のカードを元の位置に戻す'''
        draggable_pile = self.get_draggable_pile()
        for card in draggable_pile:
            if card.slot in self.solitaire.tableau:
                card.top = card.slot.top + card.slot.pile.index(card) * CARD_OFFSET
            else:
                card.top = card.slot.top
            card.left = card.slot.left
        self.solitaire.update()

    def place(self, slot):
        '''ドラッグ可能な束をスロットに配置'''
        draggable_pile = self.get_draggable_pile()
        for card in draggable_pile:
            if slot in self.solitaire.tableau:
                card.top = slot.top + len(slot.pile) * CARD_OFFSET
            else:
                card.top = slot.top
            card.left = slot.left
            if card.slot is not None:
                card.slot.pile.remove(card)
            card.slot = slot
            slot.pile.append(card)
        if self.solitaire.check_win():
            self.solitaire.winning_sequence()
        self.solitaire.update()

    def get_draggable_pile(self):
        '''現在のカードから一緒にドラッグされるカードのリストを返す'''
        if (
            self.slot is not None
            and self.slot != self.solitaire.stock
            and self.slot != self.solitaire.waste
        ):
            return self.slot.pile[self.slot.pile.index(self):]
        return [self]

    def start_drag(self, e: ft.DragStartEvent):
        if self.face_up:
            self.move_on_top()
            self.solitaire.update()

    def drag(self, e: ft.DragUpdateEvent):
        if self.face_up:
            draggable_pile = self.get_draggable_pile()
            for card in draggable_pile:
                card.top = (
                    max(0, self.top + e.delta_y)
                    + draggable_pile.index(card) * CARD_OFFSET
                )
                card.left = max(0, self.left + e.delta_x)
                self.solitaire.update()

    def drop(self, e: ft.DragEndEvent):
        if self.face_up:
            for slot in self.solitaire.tableau:
                if (
                    abs(self.top - (slot.top + len(slot.pile) * CARD_OFFSET)) < DROP_PROXIMITY
                    and abs(self.left - slot.left) < DROP_PROXIMITY
                ) and self.solitaire.check_tableau_rules(self, slot):
                    self.place(slot)
                    self.solitaire.update()
                    return
            if len(self.get_draggable_pile()) == 1:
                for slot in self.solitaire.foundations:
                    if (
                        abs(self.top - slot.top) < DROP_PROXIMITY
                        and abs(self.left - slot.left) < DROP_PROXIMITY
                    ) and self.solitaire.check_foundations_rules(self, slot):
                        self.place(slot)
                        self.solitaire.update()
                        return
            self.bounce_back()
            self.solitaire.update()

    def click(self, e):
        if self.slot in self.solitaire.tableau:
            if not self.face_up and self == self.slot.get_top_card():
                self.turn_face_up()
                self.solitaire.update()
        elif self.slot == self.solitaire.stock:
            self.move_on_top()
            self.place(self.solitaire.waste)
            self.turn_face_up()
            self.solitaire.update()

    def doubleclick(self, e):
        if self.face_up:
            self.move_on_top()
            for slot in self.solitaire.foundations:
                if self.solitaire.check_foundations_rules(self, slot):
                    self.place(slot)
                    self.solitaire.update()
                    return


SLOT_WIDTH = 70
SLOT_HEIGHT = 100


class Slot(ft.Container):
    def __init__(self, solitaire, top, left, border):
        super().__init__()
        self.pile = []
        self.width = SLOT_WIDTH
        self.height = SLOT_HEIGHT
        self.left = left
        self.top = top
        self.on_click = self.click
        self.solitaire = solitaire
        self.border = border
        self.border_radius = ft.border_radius.all(6)

    def get_top_card(self):
        if len(self.pile) > 0:
            return self.pile[-1]

    def click(self, e):
        if self == self.solitaire.stock:
            self.solitaire.restart_stock()


SOLITAIRE_WIDTH = 1000
SOLITAIRE_HEIGHT = 500


class Suite:
    def __init__(self, suite_name, suite_color):
        self.name = suite_name
        self.color = suite_color


class Rank:
    def __init__(self, card_name, card_value):
        self.name = card_name
        self.value = card_value


class Solitaire(ft.Stack):
    def __init__(self):
        super().__init__()
        self.controls = []
        self.width = SOLITAIRE_WIDTH
        self.height = SOLITAIRE_HEIGHT

    def did_mount(self):
        self.create_card_deck()
        self.create_slots()
        self.deal_cards()

    def create_card_deck(self):
        suites = [
            Suite('hearts', 'RED'),
            Suite('diamonds', 'RED'),
            Suite('clubs', 'BLACK'),
            Suite('spades', 'BLACK'),
        ]
        ranks = [
            Rank('Ace', 1),
            Rank('2', 2),
            Rank('3', 3),
            Rank('4', 4),
            Rank('5', 5),
            Rank('6', 6),
            Rank('7', 7),
            Rank('8', 8),
            Rank('9', 9),
            Rank('10', 10),
            Rank('Jack', 11),
            Rank('Queen', 12),
            Rank('King', 13),
        ]

        self.cards = []

        for suite in suites:
            for rank in ranks:
                self.cards.append(Card(solitaire=self, suite=suite, rank=rank))

    def create_slots(self):
        self.stock = Slot(solitaire=self, top=0, left=0, border=ft.border.all(1))
        self.waste = Slot(solitaire=self, top=0, left=100, border=None)
        self.foundations = []
        x = 300
        for i in range(4):
            self.foundations.append(Slot(solitaire=self, top=0, left=x, border=ft.border.all(1, 'outline')))
            x += 100
        self.tableau = []
        x = 0
        for i in range(7):
            self.tableau.append(Slot(solitaire=self, top=150, left=x, border=None))
            x += 100
        self.controls.append(self.stock)
        self.controls.append(self.waste)
        self.controls.extend(self.foundations)
        self.controls.extend(self.tableau)
        self.update()

    def deal_cards(self):
        random.shuffle(self.cards)
        self.controls.extend(self.cards)
        first_slot = 0
        remaining_cards = self.cards
        while first_slot < len(self.tableau):
            for slot in self.tableau[first_slot:]:
                top_card = remaining_cards[0]
                top_card.place(slot)
                remaining_cards.remove(top_card)
            first_slot += 1
        for card in remaining_cards:
            card.place(self.stock)
            print(f'Card in stock: {card.rank.name} {card.suite.name}')
        self.update()
        for slot in self.tableau:
            slot.get_top_card().turn_face_up()
        self.update()

    def check_foundations_rules(self, card, slot):
        top_card = slot.get_top_card()
        if top_card is not None:
            return (card.suite.name == top_card.suite.name and card.rank.value - top_card.rank.value == 1)
        else:
            return card.rank.name == 'Ace'

    def check_tableau_rules(self, card, slot):
        top_card = slot.get_top_card()
        if top_card is not None:
            return (
                card.suite.color != top_card.suite.color
                and top_card.rank.value - card.rank.value == 1
                and top_card.face_up
            )
        else:
            return card.rank.name == 'King'

    def restart_stock(self):
        while len(self.waste.pile) > 0:
            card = self.waste.get_top_card()
            card.turn_face_down()
            card.move_on_top()
            card.place(self.stock)
        self.update

    def check_win(self):
        cards_num = 0
        for slot in self.foundations:
            cards_num += len(slot.pile)
        if cards_num == 52:
            return True
        return False

    def winning_sequence(self):
        for slot in self.foundations:
            for card in slot.pile:
                card.animate_position = 2000
                card.move_on_top()
                card.top = random.randint(0, SOLITAIRE_HEIGHT)
                card.left = random.randint(0, SOLITAIRE_WIDTH)
                self.update()
        self.controls.append(ft.AlertDialog(title=ft.Text('Congratulations! You won!'), open=True))


def main(page: ft.Page):
    page.on_error = lambda e: print('Page error:', e.data)
    solitaire = Solitaire()
    page.add(solitaire)


ft.app(target=main, assets_dir='assets')

トランプの画像はチュートリアルのこちらを使用しています。

実行画面

スクリーンショット 2024-02-16 17.11.23.png
上記コードのみで画面・ゲーム処理全てを実装できました。

完成アプリをビルド

ここからは実際にビルドしていきます。

事前準備

Flutter SDK 3.16 以降をインストールし、flutter コマンドと dart コマンドの両方へのパスを PATH 環境変数に追加する必要があるそうです。
MacOSのアプリを作る場合は、追加でCocoaPodsが必要でした。

ビルド用プロジェクトの作成

以下コマンドでプロジェクトを作成します。

flet create solitaire_app

以下のようにフォルダとファイルが生成されます。
スクリーンショット 2024-02-16 15.12.01.png

今回はお試しなので、assetsに画像を配置し、main.pyを直接先ほどのコードで書き換えました。

移動して実行できることを確認します。

cd solitaire_app
flet run

Web

以下コマンドを実行します。

flet build web 

出力
スクリーンショット 2024-02-16 15.48.44.png

./build/webにファイルが出力されるので、これをPythonのhttp.server(組み込みモジュール)を使用し、Fletアプリをテストしてみます。

以下コマンドを実行します。

python -m http.server --directory build/web

ブラウザからhttp://localhost:8000にアクセスします。
スクリーンショット 2024-02-16 16.59.36.png

PCアプリ(Mac)

同じようにビルド(結構時間がかかりました。)

flet build macos

出力
スクリーンショット 2024-02-16 22.41.40.png

./build/macosに出力されており、Finderで見ると以下のように実行できるアプリケーションができています。
サイズが117.1MBと少し大きいです。
スクリーンショット 2024-02-16 22.45.34.png

起動すると以下のようになりました。(ナイトモードも反映されてました。)
スクリーンショット 2024-02-16 22.49.37.png

他のOS

今回は試してはないですが、以下のコマンドを使用することでそれぞれのOS用にビルドできるみたいです。

  • Linux デスクトップ アプリケーションを構築
    flet build linux
  • Windows デスクトップ アプリケーションを構築
    flet build windows

Android

ビルド

flet build apk

出力
スクリーンショット 2024-02-17 0.02.49.png

./build/apkに出力されています。
これを実機で動かすと以下のようになりました。
test.gif
画面はサイズを固定してるので、右側が若干見切れていますが、動作に問題はなかったです。

iOS

プロビジョニングプロファイルなどを用意する必要はありますが、iOSも同様に1コマンドでIPAファイルまで生成できるようです。

触ってみた感想

強みは簡単に多様なプラットフォームに対応できる点と画面のパーツが一通り揃っていてドキュメントが豊富なところ
弱みは大規模アプリケーションの開発には制限があり、データバインディングの機構が無い=画面更新の処理も自力で書く必要があるので、コードが膨大になりそうです。

あとはCocoaPodsなど、ビルドする対象に合わせて別途パッケージなどが必要ですので、PC環境が汚れやすいです。
私の場合ですと、以前構築していたRubyやFlutterの環境が原因で、競合を起こしたりと環境を構築するのが結構大変でした。

小〜中規模のプロジェクトで、バックエンドにPythonを使いたい場合や、マルチプラットフォーム対応の柔軟性が欲しい場合には最適かと思います。
逆に、マルチプラットフォームを必要としない場合や、大規模なアプリケーション、特定の高度な要件には適していないと感じました。

発展途上ではありますが、頻繁にアップデートされていますので、今後も追っていきたいと思えるようなライブラリです。

6
4
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
6
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?