はじめに
社内のハッカソンが開催されたので、そこでFletライブラリを触ってみました。
https://flet.dev
Fletとは?
Fletは、フロントエンド開発の経験がなくても、Web、デスクトップ、モバイルアプリを好みの言語で構築できるクロスプラットフォームフレームワークです。
特徴
GoogleのFlutterをベースに作られており、以下の6つの特徴があります。
-
アイデアからアプリまでが迅速
内部ツール、チーム用ダッシュボード、週末プロジェクト、データ入力フォーム、キオスクアプリ、高忠実度プロトタイプなど、Fletは迅速にインタラクティブなアプリを作成するのに適しています。 -
シンプルなアーキテクチャ
JavaScriptフロントエンド、REST APIバックエンド、データベース、キャッシュなどの複雑なアーキテクチャを必要とせず、Pythonだけでモノリス状のステートフルアプリを作成でき、マルチユーザー、リアルタイムのシングルページアプリケーション(SPA)を提供します。 -
バッテリー同梱
開発を始めるために必要なのはお気に入りのIDEまたはテキストエディターのみです。SDKや多数の依存関係、複雑なツールは必要ありません。Fletには、アセットホスティングとデスクトップクライアントを備えた内蔵Webサーバーがあります。 -
Flutterによる強化
Flet UIはFlutterを使って構築されており、プロフェッショナルな外観のアプリを任意のプラットフォームに提供できます。FletはFlutterモデルを単純化し、より小さな「ウィジェット」を即使用可能な「コントロール」に組み合わせ、命令型プログラミングモデルを採用しています。 -
多言語対応
Fletは言語非依存なので、チームの誰もがお気に入りの言語でFletアプリを開発できます。現在はPythonがサポートされており、Go、C#などが次に追加される予定です。 -
あらゆるデバイスへの配信
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)
ソリティア ゲームの概念実証アプリ
細かくやると長くなるのでここからは一気に進めていきます。
気になる方は公式をチェックしてください。
カードの生成とカードを置くスロットを作ってドラッグ&ドロップできるようにして概念実証を行います。
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')
トランプの画像はチュートリアルのこちらを使用しています。
実行画面
完成アプリをビルド
ここからは実際にビルドしていきます。
事前準備
Flutter SDK 3.16 以降をインストールし、flutter コマンドと dart コマンドの両方へのパスを PATH 環境変数に追加する必要があるそうです。
MacOSのアプリを作る場合は、追加でCocoaPodsが必要でした。
ビルド用プロジェクトの作成
以下コマンドでプロジェクトを作成します。
flet create solitaire_app
今回はお試しなので、assetsに画像を配置し、main.pyを直接先ほどのコードで書き換えました。
移動して実行できることを確認します。
cd solitaire_app
flet run
Web
以下コマンドを実行します。
flet build web
./build/web
にファイルが出力されるので、これをPythonのhttp.server(組み込みモジュール)を使用し、Fletアプリをテストしてみます。
以下コマンドを実行します。
python -m http.server --directory build/web
ブラウザからhttp://localhost:8000
にアクセスします。
PCアプリ(Mac)
同じようにビルド(結構時間がかかりました。)
flet build macos
./build/macos
に出力されており、Finderで見ると以下のように実行できるアプリケーションができています。
サイズが117.1MBと少し大きいです。
起動すると以下のようになりました。(ナイトモードも反映されてました。)
他のOS
今回は試してはないですが、以下のコマンドを使用することでそれぞれのOS用にビルドできるみたいです。
- Linux デスクトップ アプリケーションを構築
flet build linux
- Windows デスクトップ アプリケーションを構築
flet build windows
Android
ビルド
flet build apk
./build/apk
に出力されています。
これを実機で動かすと以下のようになりました。
画面はサイズを固定してるので、右側が若干見切れていますが、動作に問題はなかったです。
iOS
プロビジョニングプロファイルなどを用意する必要はありますが、iOSも同様に1コマンドでIPAファイルまで生成できるようです。
触ってみた感想
強みは簡単に多様なプラットフォームに対応できる点と画面のパーツが一通り揃っていてドキュメントが豊富なところ
弱みは大規模アプリケーションの開発には制限があり、データバインディングの機構が無い=画面更新の処理も自力で書く必要があるので、コードが膨大になりそうです。
あとはCocoaPodsなど、ビルドする対象に合わせて別途パッケージなどが必要ですので、PC環境が汚れやすいです。
私の場合ですと、以前構築していたRubyやFlutterの環境が原因で、競合を起こしたりと環境を構築するのが結構大変でした。
小〜中規模のプロジェクトで、バックエンドにPythonを使いたい場合や、マルチプラットフォーム対応の柔軟性が欲しい場合には最適かと思います。
逆に、マルチプラットフォームを必要としない場合や、大規模なアプリケーション、特定の高度な要件には適していないと感じました。
発展途上ではありますが、頻繁にアップデートされていますので、今後も追っていきたいと思えるようなライブラリです。