One small app.
Four Python Android paths.
Benchmark Notes fixes the dataset and user-visible behavior, then measures the Android package each stack actually produces. The programs and raw samples are included so the comparison can be challenged and repeated.
Corrected on 2026-09-11
The ApkPy figures first published on this page -- 590 ms and 46.3 MiB -- came from an app whose list was empty on the phone. ApkPy 1.3.0 compiled its module-level comprehension to nothing without a word, and the smoke check only looked for four labels. Measured again with all 100 notes on screen, ApkPy still started about twice as soon as BeeWare/Toga in the same session, and held 67.0 MiB against 83.4 MiB. The 2026-08-17 record is kept below, marked.
Scenario 2026-09-11: ApkPy 1.8.0¶
Only ApkPy was measured again. Its program had to be rewritten to build, and a functional check now proves that each app does what the contract says before it is timed. BeeWare/Toga was rebuilt and measured in the same session as a control, because cold start on this emulator moved by more than 2x within one day.
| Stack, same session | APK | Cold start, first three | Cold start, median of ten | PSS median | Functional check |
|---|---|---|---|---|---|
| ApkPy 1.8.0 candidate | 5.37 MiB | 2,565 ms | 2,855 ms | 67.0 MiB | 5/5 |
| BeeWare/Toga, control | 34.46 MiB | 5,536 ms | 5,573 ms | 83.4 MiB | 4/5 |
For this app, in one session, ApkPy's debug APK was 6.4x smaller, it started about 2x sooner, and its PSS was about a fifth lower than BeeWare/Toga's.
Both are debug builds, as in 2026-08-17: measuring a shrunk ApkPy release against unshrunk competitors would not be a comparison. For scale, the same ApkPy program signed as a release, with R8, comes to 1,518 KB -- the debug APK carries the whole of AndroidX and Material, and R8 drops what the app never calls. The other stacks have their own release settings, not measured here.
Do not mix this table with the 2026-08-17 one
The same BeeWare/Toga program started in 2,334 ms on 2026-08-17, and in 2,145 ms and then 5,573 ms in two sessions on 2026-09-11. An absolute time describes the emulator at that moment, so ApkPy's 2026-09-11 time cannot be compared with Flet's or BeeWare/Toga's from 2026-08-17. Compare within one session.
What changed, and why:
- The ApkPy program. The 2026-08-17 version built its notes with a
comprehension at module level and
{index:03d}. Neither is translated, and ApkPy now stops the build instead of compiling them to nothing. The notes are filled inlifecycle(home, on_mount=...)with aforloop: 104 lines, 81 of code, against 83 and 66. - A functional check. Before timing, each app has to open with 100 notes,
filter to 20 favourites, show 100 again, add a note, and find exactly one
match for
Note 010-- read back from the screen every time. BeeWare/Toga's search had not reached one match after 20 seconds in the two slower sessions; it had in the first. - A warm-up and ten launches. Each app is cold-started until three launches in a row agree within 15 %, then timed ten times. The median of the first three is the 2026-08-17 method.
- Flet was not rebuilt: its Android build needs Windows Developer Mode on
this host. Its 2026-08-17 APK was
arm64-v8a, running through ARM translation on the x86_64 emulator.
The generated Java also shows where ApkPy's start-up goes: each of the 100 appends reads the whole list back from JSON and writes it out again, before the first frame. That was not profiled, but it is the first place to look for a faster start.
The scenario folder has the program, the measurement script, every session of the day -- including three that were discarded, each with its reason -- and a README with the details.
Result¶
The ApkPy row of this 2026-08-17 run measured an empty list
Everything from here down is the run recorded on 2026-08-17, unchanged. Its ApkPy app opened with no notes on the phone, so its cold start and memory are not a like-for-like comparison. Its APK size still compares: the code it carried is the same size either way.
All available APKs were debug builds installed on the same Pixel 9 emulator, Android API 35 and x86_64 ABI. “Cached build” excludes first-time toolchain downloads.
Development-build evidence
Android recommends measuring optimized release-like builds for conclusions about production performance. This run intentionally uses debug builds so every artifact is inspectable and uses the same build class. Treat it as a reproducible development-floor comparison, not a store-release ranking.
| Stack | App source | APK | Cached build | Cold start | PSS | UI smoke |
|---|---|---|---|---|---|---|
| ApkPy 1.3.0 candidate | 83 lines | 5.38 MiB | 20.9 s | 590 ms | 46.3 MiB | 4/4 |
| Flet 0.86.5 | 70 lines | 25.28 MiB | 143.0 s | 1,824 ms | 206.1 MiB | 4/4 |
| BeeWare/Toga 0.5.6 | 73 lines | 33.98 MiB | 17.8 s | 2,334 ms | 79.7 MiB | 4/4 |
| Kivy 2.3.1 | 78 lines | not produced | not produced | not produced | not produced | source smoke |
For this app, ApkPy's debug APK was 4.70x smaller than Flet and 6.32x smaller than BeeWare/Toga. Its measured PSS was 4.46x lower than Flet and 1.72x lower than BeeWare/Toga -- for an app whose list was empty. In the 2026-09-11 session, with 100 notes on screen, it was 1.24x lower than BeeWare/Toga's.
Read the claim at the correct size
This is evidence for one deliberately small application, not a universal framework ranking. Release signing, R8, assets, plugins, backend code and application architecture can change size, memory and startup.
What this result answers¶
| Question | Answer from this run |
|---|---|
| Which measured debug artifact was smallest? | ApkPy, at 5.38 MiB. |
| Which measured app had the lowest median cold start? | ApkPy, at 590 ms -- but its list was empty. See the 2026-09-11 scenario above. |
| Which measured app had the lowest median total PSS? | ApkPy, at 46.3 MiB -- but its list was empty. See the 2026-09-11 scenario above. |
| Was every implementation asked to do the same thing? | They were asked to. ApkPy's did not: its 100 records never reached the screen. |
| Does this prove every ApkPy app beats every alternative? | No. The result is scoped to this app, device, ABI and debug configuration. |
| Is Kivy slower or larger here? | Unknown. No Kivy APK was produced, so no Android number is claimed. |
Packaging paths compared¶
| Stack | Android path used by the measured program |
|---|---|
| ApkPy | Python declaration transpiled to generated Java/XML, then built by Gradle. |
| Flet | Python application packaged with the Flet/Flutter Android toolchain. |
| BeeWare/Toga | Python application packaged through Briefcase. |
| Kivy | Source prepared for Buildozer/python-for-Android; Android packaging was blocked on this Windows host. |
The benchmark compares complete application artifacts, not just source syntax. It does not attempt to make the generated internals identical because that is precisely where each stack makes a different architectural choice.
The app contract¶
Every implementation starts with the same deterministic 100-note list and must expose the same four behaviors:
- search note title and subtitle;
- show favorites only;
- add a new note;
- scroll the result list.
There is no network, database, media asset or framework-specific plugin. This keeps the run focused on the small UI/runtime floor. It deliberately does not measure the new Data Core.
The Android UI tree was checked for the same four labels after launch:
Benchmark Notes, Search notes, FAVORITES ONLY and ADD NOTE. ApkPy,
Flet and BeeWare/Toga each passed 4/4.
Exact application code¶
Only application programs are shown below. No private ApkPy compiler source is included.
from apkpy_lib import Screen, button, inputs, label, list_view, run, state
NOTES = [
{
"title": f"Note {index:03d}",
"subtitle": "Same deterministic row in every framework",
"meta": "Favorite" if index % 5 == 0 else "Standard",
}
for index in range(1, 101)
]
home = Screen(id="home", scroll=True)
query_state = state("", id="benchmark_query")
favorites_state = state(False, id="benchmark_favorites")
next_state = state(101, id="benchmark_next")
def visible_notes():
query = query_state.get().strip().lower()
return [
item for item in NOTES
if (not favorites_state.get() or item["meta"] == "Favorite")
and (not query or query in item["title"].lower()
or query in item["subtitle"].lower())
]
def refresh_rows():
notes.set_items(visible_notes())
status.set_value(f"Showing {len(visible_notes())} notes")
def search_changed(value):
query_state.set(value)
refresh_rows()
def toggle_favorites(value=None):
favorites_state.toggle()
refresh_rows()
def add_note():
index = next_state.get()
NOTES.append({
"title": f"Note {index:03d}",
"subtitle": "Added from the shared benchmark action",
"meta": "Standard",
})
next_state.increment()
refresh_rows()
label("BENCHMARK NOTES / 0.1", id="kicker", screen=home)
label("One small app. Four native packaging paths.", id="title", screen=home)
label(
"Search 100 notes, add a row, toggle favorites and scroll the same dataset.",
id="copy", screen=home,
)
inputs(
"Search notes", id="search", type="search", screen=home,
on_change=search_changed,
)
button("FAVORITES ONLY", id="favorites", icon="star", screen=home,
command=toggle_favorites)
button("ADD NOTE", id="add", icon="add", screen=home, command=add_note)
status = label("Showing 100 notes", id="status", screen=home)
notes = list_view(NOTES, id="notes", screen=home, rich=True)
style = """
home { background-color: #090B10; padding: 18px; }
kicker { color: #50E3C2; font-size: 11px; font-weight: bold; margin-bottom: 8px; }
title { color: #F5F7FB; font-size: 26px; font-weight: bold; margin-bottom: 8px; }
copy, status { color: #9EA8BA; font-size: 13px; margin-bottom: 12px; }
search { background-color: #10141C; color: #F5F7FB; placeholder-color: #9EA8BA; border-color: #30394A; border-radius: 14px; min-height: 48px; margin-bottom: 10px; }
favorites, add { border-radius: 14px; min-height: 48px; font-weight: bold; margin-bottom: 10px; }
favorites { background-color: #1D5660; color: #FFFFFF; }
add { background-color: #7C5CFF; color: #24164A; }
notes { height: 560px; background-color: #090B10; color: #F5F7FB; item-background-color: #151922; title-color: #F5F7FB; subtitle-color: #9EA8BA; meta-color: #50E3C2; item-border-color: #30394A; }
"""
run(start_screen=home)
import flet as ft
NOTES = [
{"title": f"Note {index:03d}",
"subtitle": "Same deterministic row in every framework",
"meta": "Favorite" if index % 5 == 0 else "Standard"}
for index in range(1, 101)
]
def main(page: ft.Page):
page.title = "Benchmark Notes"
page.theme_mode = ft.ThemeMode.DARK
page.padding = 18
query = {"value": ""}
favorites = {"value": False}
next_index = {"value": 101}
rows = ft.Column(spacing=1, scroll=ft.ScrollMode.AUTO, expand=True)
status = ft.Text("Showing 100 notes", color="#9EA8BA", size=13)
def visible():
q = query["value"].strip().lower()
return [item for item in NOTES if (not favorites["value"] or item["meta"] == "Favorite") and (not q or q in item["title"].lower() or q in item["subtitle"].lower())]
def refresh():
rows.controls = [
ft.Container(
content=ft.Column([
ft.Text(item["title"], weight=ft.FontWeight.BOLD),
ft.Text(f'{item["subtitle"]} · {item["meta"]}', color="#9EA8BA", size=12),
], spacing=3),
bgcolor="#151922", padding=10,
)
for item in visible()
]
status.value = f"Showing {len(visible())} notes"
page.update()
def on_search(event):
query["value"] = event.control.value
refresh()
def toggle_favorites(_event):
favorites["value"] = not favorites["value"]
refresh()
def add_note(_event):
index = next_index["value"]
NOTES.append({"title": f"Note {index:03d}", "subtitle": "Added from the shared benchmark action", "meta": "Standard"})
next_index["value"] += 1
refresh()
page.add(ft.Column([
ft.Text("BENCHMARK NOTES / 0.1", color="#50E3C2", size=11, weight=ft.FontWeight.BOLD),
ft.Text("One small app. Four native packaging paths.", size=26, weight=ft.FontWeight.BOLD),
ft.Text("Search 100 notes, add a row, toggle favorites and scroll the same dataset.", color="#9EA8BA", size=13),
ft.TextField(hint_text="Search notes", on_change=on_search, border_radius=14, height=48),
ft.Row([
ft.FilledButton("FAVORITES ONLY", on_click=toggle_favorites),
ft.FilledButton("ADD NOTE", on_click=add_note),
], spacing=10),
status,
rows,
], expand=True, spacing=10))
refresh()
if __name__ == "__main__":
ft.run(main)
from kivy.app import App
from kivy.metrics import dp
from kivy.uix.boxlayout import BoxLayout
from kivy.uix.button import Button
from kivy.uix.label import Label
from kivy.uix.scrollview import ScrollView
from kivy.uix.textinput import TextInput
NOTES = [
{"title": f"Note {index:03d}",
"subtitle": "Same deterministic row in every framework",
"meta": "Favorite" if index % 5 == 0 else "Standard"}
for index in range(1, 101)
]
class BenchmarkNotes(App):
def build(self):
self.query = ""
self.favorites = False
self.next_index = 101
root = BoxLayout(orientation="vertical", padding=dp(18), spacing=dp(10))
root.background_color = (0.035, 0.043, 0.063, 1)
root.add_widget(Label(text="BENCHMARK NOTES / 0.1", size_hint_y=None, height=dp(22), color=(0.31, 0.89, 0.76, 1)))
root.add_widget(Label(text="One small app. Four native packaging paths.", size_hint_y=None, height=dp(62), font_size=dp(24), halign="left", text_size=(None, None)))
root.add_widget(Label(text="Search 100 notes, add a row, toggle favorites and scroll the same dataset.", size_hint_y=None, height=dp(48), color=(0.62, 0.66, 0.73, 1)))
self.search = TextInput(hint_text="Search notes", multiline=False, size_hint_y=None, height=dp(48))
self.search.bind(text=self.on_search)
root.add_widget(self.search)
actions = BoxLayout(size_hint_y=None, height=dp(48), spacing=dp(10))
favorites = Button(text="FAVORITES ONLY")
favorites.bind(on_release=self.toggle_favorites)
add = Button(text="ADD NOTE")
add.bind(on_release=self.add_note)
actions.add_widget(favorites)
actions.add_widget(add)
root.add_widget(actions)
self.status = Label(text="Showing 100 notes", size_hint_y=None, height=dp(26), color=(0.62, 0.66, 0.73, 1))
root.add_widget(self.status)
self.scroll = ScrollView(do_scroll_x=False)
self.rows = BoxLayout(orientation="vertical", spacing=dp(1), size_hint_y=None)
self.rows.bind(minimum_height=self.rows.setter("height"))
self.scroll.add_widget(self.rows)
root.add_widget(self.scroll)
self.refresh_rows()
return root
def visible(self):
query = self.query.strip().lower()
return [item for item in NOTES if (not self.favorites or item["meta"] == "Favorite") and (not query or query in item["title"].lower() or query in item["subtitle"].lower())]
def refresh_rows(self):
self.rows.clear_widgets()
items = self.visible()
for item in items:
row = BoxLayout(orientation="vertical", padding=dp(10), size_hint_y=None, height=dp(66))
row.add_widget(Label(text=item["title"], halign="left", text_size=(None, None)))
row.add_widget(Label(text=f'{item["subtitle"]} · {item["meta"]}', color=(0.62, 0.66, 0.73, 1), halign="left", text_size=(None, None)))
self.rows.add_widget(row)
self.status.text = f"Showing {len(items)} notes"
def on_search(self, _widget, value):
self.query = value
self.refresh_rows()
def toggle_favorites(self, _button):
self.favorites = not self.favorites
self.refresh_rows()
def add_note(self, _button):
NOTES.append({"title": f"Note {self.next_index:03d}", "subtitle": "Added from the shared benchmark action", "meta": "Standard"})
self.next_index += 1
self.refresh_rows()
if __name__ == "__main__":
BenchmarkNotes().run()
import toga
from toga.style import Pack
from toga.style.pack import COLUMN, ROW
NOTES = [
{"title": f"Note {index:03d}",
"subtitle": "Same deterministic row in every framework",
"meta": "Favorite" if index % 5 == 0 else "Standard"}
for index in range(1, 101)
]
class BenchmarkNotes(toga.App):
def startup(self):
self.query = ""
self.favorites = False
self.next_index = 101
self.rows = toga.Box(style=Pack(direction=COLUMN, flex=1))
self.status = toga.Label("Showing 100 notes", style=Pack(padding_bottom=8))
self.search = toga.TextInput(placeholder="Search notes", on_change=self.on_search, style=Pack(flex=1, padding_right=8))
search_row = toga.Box(children=[self.search], style=Pack(direction=ROW, padding_bottom=8))
favorites = toga.Button("FAVORITES ONLY", on_press=self.toggle_favorites, style=Pack(flex=1, padding_right=5))
add = toga.Button("ADD NOTE", on_press=self.add_note, style=Pack(flex=1, padding_left=5))
actions = toga.Box(children=[favorites, add], style=Pack(direction=ROW, padding_bottom=8))
content = toga.Box(children=[
toga.Label("BENCHMARK NOTES / 0.1", style=Pack(padding_bottom=8)),
toga.Label("One small app. Four native packaging paths.", style=Pack(padding_bottom=8)),
toga.Label("Search 100 notes, add a row, toggle favorites and scroll the same dataset.", style=Pack(padding_bottom=8)),
search_row, actions, self.status, self.rows,
], style=Pack(direction=COLUMN, padding=18, flex=1))
self.main_window = toga.MainWindow(title=self.formal_name)
self.main_window.content = content
self.main_window.show()
self.refresh_rows()
def visible(self):
query = self.query.strip().lower()
return [item for item in NOTES if (not self.favorites or item["meta"] == "Favorite") and (not query or query in item["title"].lower() or query in item["subtitle"].lower())]
def refresh_rows(self):
self.rows.children.clear()
items = self.visible()
for item in items:
self.rows.add(toga.Box(children=[
toga.Label(item["title"], style=Pack(font_weight="bold")),
toga.Label(f'{item["subtitle"]} · {item["meta"]}', style=Pack(font_size=10)),
], style=Pack(direction=COLUMN, padding=10)))
self.status.text = f"Showing {len(items)} notes"
def on_search(self, widget):
self.query = widget.value or ""
self.refresh_rows()
def toggle_favorites(self, _widget):
self.favorites = not self.favorites
self.refresh_rows()
def add_note(self, _widget):
index = self.next_index
NOTES.append({"title": f"Note {index:03d}", "subtitle": "Added from the shared benchmark action", "meta": "Standard"})
self.next_index += 1
self.refresh_rows()
def main():
"""Launch the same benchmark app through Briefcase's Android entrypoint."""
return BenchmarkNotes(
"Benchmark Notes",
"com.apkpy.benchmark.benchmark_notes",
).main_loop()
# src/benchmark_notes/__main__.py
from .app import main
if __name__ == "__main__":
main()
The public repository stores the exact, unshortened programs and packaging
files under
benchmarks/benchmark-notes.
Source lines¶
“Physical” counts every line in the application entry files, including blank
lines. “Code” removes blank lines and lines beginning with #. Generated Java,
XML and Gradle files are not counted; neither are TOML/spec packaging files.
| Stack | Entry files | Physical | Code |
|---|---|---|---|
| ApkPy | writehere.py |
83 | 66 |
| Flet | main.py |
70 | 58 |
| Kivy | main.py |
78 | 67 |
| BeeWare/Toga | app.py + __main__.py |
73 | 60 |
Line count helps audit how much handwritten app code was compared. It is not, on its own, a quality or productivity score.
How the Android measurements were taken¶
- Build each debug APK after dependencies were cached.
- Install it on the same emulator.
- Force-stop before every launch.
- Run
adb shell am start -Wthree times and take the median. - Wait two seconds and read total PSS from
dumpsys meminfo. - Verify the four required labels in the UI tree.
For a future release-grade performance study, the same contract should be rebuilt with release optimization, measured with Android Macrobenchmark and inspected with Perfetto. This recorded run is kept immutable rather than silently replacing its debug samples with a different methodology.
The raw cold-start samples were:
| Stack | Run 1 | Run 2 | Run 3 | Median |
|---|---|---|---|---|
| ApkPy | 611 ms | 590 ms | 580 ms | 590 ms |
| Flet | 1,824 ms | 1,845 ms | 1,733 ms | 1,824 ms |
| BeeWare/Toga | 2,346 ms | 2,334 ms | 2,327 ms | 2,334 ms |
The raw PSS samples are included in the benchmark JSON, together with SHA-256 hashes for each measured APK.
Why Kivy has no APK row¶
The Kivy 2.3.1 application passed a Python source smoke check. The Windows host did not have Buildozer/python-for-Android or an installed WSL distribution, so it could not produce a Kivy Android artifact in this run. An empty measurement is more useful than a guessed one.
Kivy's own Android packaging guide recommends Buildozer as the easiest path and
directs Windows users to WSL. That environment was unavailable for this run;
the source and buildozer.spec remain included so a later Linux/WSL run can
fill the missing row without changing the application contract.
Reproduce or challenge it¶
The public evidence bundle contains:
- the four unshortened application programs;
- every packaging file used by those programs;
- APK size, SHA-256, build-time, launch and memory samples;
- emulator and tool versions;
- the PowerShell device-measurement script.
Start with the raw benchmark folder and read its README before comparing a new run. Keep the dataset, behavior, device, ABI and build class unchanged; otherwise publish the result as a new scenario instead of overwriting this one.
Primary measurement and packaging references:
- Android performance measurement
- Android app startup time
- Kivy Android packaging
- BeeWare Android tutorial
What 1.3.0 adds beyond this benchmark¶
Benchmark Notes deliberately keeps its dataset in memory. ApkPy 1.3.0 adds a separate production-oriented Data Core: typed SQLite models, bound filters, asynchronous CRUD, batch writes, transactions, pagination and explicit migrations with backup/restore safety.
Use the benchmark to inspect the small application/runtime floor. Use the 1.3.0 release page and Knowledge Vault example to evaluate the data layer.