Skip to main content

A look at Chaquopy the Python SDK for Android

I recently heard about chaquopy - A "Python SDK for Android" and was really excited. There's already a demo app on the Play store for python 2 and python 3. As the author of enaml-native, I had to take a look to see how others are trying to tackle the python on android challenge.  So here's a short review and discussion of my initial thoughts on chaquopy.

Demo

I encourage you to try out the demo yourself to get a feel for it, but here's a short demo of what it does.



Initial thoughts. It starts reasonably fast (about 3-4 seconds on my phone).  There's a noticeable delay when starting the python activity on my phone (much smoother on the emulator), but other than that the widgets look great and interaction is smooth!

The apk is 11.54 MB and the app is only 18 MB installed (21MB if you include the cache), which is very good for bundling python!

Also, the build process was a piece of cake!  I cloned the repo, opened in android studio, pressed play, and it worked!  This is because the build system includes precompiled python and libs for you.

Code


The Android UI demo part is written in python. The rest of the app is Java. Here's the code ui_demo.py

from __future__ import absolute_import, division, print_function

from java import dynamic_proxy, jboolean, jvoid, Override, static_proxy

from android.app import AlertDialog
from android.content import Context, DialogInterface
from android.graphics.drawable import ColorDrawable
from android.os import Bundle
from android.support.v4.app import DialogFragment
from android.support.v7.app import AppCompatActivity
from android.support.v7.preference import Preference, PreferenceFragmentCompat
from android.view import Menu, MenuItem, View
from java.lang import String

from com.chaquo.python.demo import R
from .utils import view_source


class UIDemoActivity(static_proxy(AppCompatActivity)):
    @Override(jvoid, [Bundle])
    def onCreate(self, state):
        AppCompatActivity.onCreate(self, state)
        if state is None:
            state = Bundle()
        self.setContentView(R.layout.activity_menu)
        self.findViewById(R.id.tvCaption).setText(R.string.demo_caption)

        self.title_drawable = ColorDrawable()
        self.getSupportActionBar().setBackgroundDrawable(self.title_drawable)
        self.title_drawable.setColor(
            state.getInt("title_color", self.getResources().getColor(R.color.blue)))

        self.wvSource = self.findViewById(R.id.wvSource)
        view_source(self, self.wvSource, "ui_demo.py")
        self.wvSource.setVisibility(state.getInt("source_visibility", View.GONE))

        self.getSupportFragmentManager().beginTransaction()\
            .replace(R.id.flMenu, MenuFragment()).commit()

    @Override(jvoid, [Bundle])
    def onSaveInstanceState(self, state):
        state.putInt("source_visibility", self.wvSource.getVisibility())
        state.putInt("title_color", self.title_drawable.getColor())

    @Override(jboolean, [Menu])
    def onCreateOptionsMenu(self, menu):
        self.getMenuInflater().inflate(R.menu.view_source, menu)
        return True
    @Override(jboolean, [MenuItem])
    def onOptionsItemSelected(self, item):
        id = item.getItemId()
        if id == R.id.menu_source:
            vis = self.wvSource.getVisibility()
            new_vis = View.VISIBLE if (vis == View.GONE) else View.GONE
            self.wvSource.setVisibility(new_vis)
            return True        else:
            return False

class MenuFragment(static_proxy(PreferenceFragmentCompat)):
    @Override(jvoid, [Bundle, String])
    def onCreatePreferences(self, state, rootKey):
        self.addPreferencesFromResource(R.xml.activity_ui_demo)

        from android.media import AudioManager, SoundPool
        self.sound_pool = SoundPool(1, AudioManager.STREAM_MUSIC, 0)
        self.sound_id = self.sound_pool.load(self.getActivity(), R.raw.sound, 1)

    @Override(jboolean, [Preference])
    def onPreferenceTreeClick(self, pref):
        method = getattr(self, pref.getKey())
        if method:
            method(self.getActivity())
            return True        else:
            return False
    def demo_dialog(self, activity):
        ColorDialog().show(self.getFragmentManager(), "color")

    def demo_notify(self, activity):
        from android.app import Notification
        builder = Notification.Builder(activity)
        builder.setSmallIcon(R.drawable.ic_launcher)
        builder.setContentTitle(
            activity.getString(R.string.demo_notify_title))
        builder.setContentText(
            activity.getString(R.string.demo_notify_text))
        activity.getSystemService(Context.NOTIFICATION_SERVICE)\
            .notify(0, builder.getNotification())

    def demo_toast(self, activity):
        from android.widget import Toast
        Toast.makeText(activity, R.string.demo_toast_text,                       Toast.LENGTH_SHORT).show()

    def demo_sound(self, activity):
        self.sound_pool.play(self.sound_id, 1, 1, 0, 0, 1)

    def demo_vibrate(self, activity):
        activity.getSystemService(Context.VIBRATOR_SERVICE)\
            .vibrate(200)


class ColorDialog(static_proxy(DialogFragment)):
    @Override(AlertDialog, [Bundle])
    def onCreateDialog(self, state):
        activity = self.getActivity()
        builder = AlertDialog.Builder(activity)
        builder.setTitle(R.string.demo_dialog_title)
        builder.setMessage(R.string.demo_dialog_text)

        class Listener(dynamic_proxy(DialogInterface.OnClickListener)):
            def __init__(self, color_res):
                super(Listener, self).__init__()
                self.color = activity.getResources().getColor(color_res)

            def onClick(self, dialog, which):
                activity.title_drawable.setColor(self.color)

        builder.setNegativeButton(R.string.red, Listener(R.color.red))
        builder.setNeutralButton(R.string.green, Listener(R.color.green))
        builder.setPositiveButton(R.string.blue, Listener(R.color.blue))
        return builder.create()


The code, while python, looks very similar to Java.  You can see there's no async stuff going on here (like enaml-native), everything is being used directly, which is nice.  I also like the annotations much more than the pyjnius like syntax. It looks very similar than jnius and I assume that's what it's using for the python side of the JNI (or a similar implementation). 

Also it requires a layout file for the view in xml here https://github.com/chaquo/chaquopy/blob/master/app/src/main/res/layout/activity_menu.xml

I must admit this seems a bit complicated for what it does. If you don't know the android api's this will really leave you lost.  As a comparison, I wrote the same view using enaml-native. Here's the code.


from enamlnative.core.api import Looper, Conditionalfrom enamlnative.widgets.api import (
    Flexbox, Toolbar, Button, Dialog, TextView, View, ViewPager, PagerFragment, WebView, ScrollView
)
from enamlnative.android.app import AndroidApplication

app = AndroidApplication.instance()

COLORS = {
    "blue": "#3F51B5",
    "red": "#b53f3f",
    "green": "#3f8e3d"}

enamldef Spacer(View):
    height = 1
    background_color = "#ccc"

enamldef ListButton(Button):
    flat = True
    all_caps = False
    text_alignment = "left"
    padding = (20, 20, 10, 20)

enamldef HomeScreen(PagerFragment): page:
    Flexbox:
        flex_direction = "column"
        background_color = "#FFF"
        Toolbar:
            height = "wrap_content"
            min_height = 60
            background_color << COLORS['blue']
            title = "Enaml demo"
            title_color = "#FFF"
        ListButton:
            text = "Android UI demo"
            clicked :: page.parent.current_index = 1

enamldef AndroidDemoScreen(PagerFragment): page:
    Flexbox: view:
        attr color = "blue"
        attr show_source = False
        flex_direction = "column"
        align_items = "stretch"
        #align_content = "stretch"
        background_color = "#FFF"
        transition = "default"
        Toolbar:
            height = "wrap_content"
            min_height = 60
            background_color << COLORS[view.color]

            content_padding = (0,0,0,0)
            Flexbox:
                width = "match_parent"
                justify_content = "space_between"
                align_items = "center"
                TextView:
                    text = "Android UI demo"
                    padding = (10, 10, 10, 10)
                    font_style = "bold"
                    text_size = 18
                    text_color = "#FFF"
                Button:
                    flat = True
                    text = "View source"
                    text_size = 14
                    text_color = "#FFF"
                    clicked :: view.show_source = not view.show_source

        TextView:
            height = "wrap_content"
            width = "match_parent"
            min_height = 80
            text = "This activity was written entirely in Python using enaml-native. " \
                   "To view it's source code, press the button above."

            padding = (10, 10, 10, 10)

        ScrollView:
            height = "wrap_content"
            width = "match_parent"
            Flexbox:
                flex_direction = "column"
                height = 200
                Spacer:
                    pass
                ListButton:
                    text = "Dialog box"
                    clicked :: dialog.show = True
                Spacer:
                        pass
                ListButton:
                    text = "Notification" 
                   clicked :: dialog.show = True
                Spacer:
                        pass
                ListButton:
                    text = "Toast"
                    clicked :: app.show_toast("Toast from Python")
                Spacer:
                    pass
                ListButton:
                    text = "Sound"                     
                    clicked :: app.show_toast("Toast from Python")
                Spacer:
                    pass
                ListButton:
                    text = "Vibration"
                    clicked :: app.show_toast("Toast from Python")

                Spacer:
                    pass
        Conditional:
            condition << view.show_source
            WebView:
                width = "match_parent"
                height = 300
                activated ::
                    with open(__file__) as f:
                        self.source = """<?xml version=\"1.0\" encoding=\"UTF-8\" ?>
                        <html>

                            <head>
                                <link rel="stylesheet"                                      href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/styles/default.min.css">                                <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/9.12.0/highlight.min.js"></script>                            </head>                            <body><pre><code class="python">{}</code></pre>                            <script>hljs.initHighlightingOnLoad();</script>                            </body>                        </html>""".format(f.read())
                source = ""
                javascript_enabled = True
         Dialog: dialog:
            Flexbox:
                flex_direction = "column" 
                TextView:
                    text = "Dialog from Python"                    font_style = "bold"                    text_size = 18                    padding = (20, 20, 20, 0)
                TextView:
                    text = "Select title color"                    padding = (20, 0, 0, 0)
                Flexbox:
                    width = "match_parent"
                    justify_content = "space_between"
                    padding = (0, 20, 0, 0)
                    Button:
                        flat = True 
                        text = "green" 
                        text_color = COLORS[self.text]
                        clicked ::
                            view.color = self.text
                            dialog.show = False
                    Button:
                        flat = True
                        text = "red" 
                        text_color = COLORS[self.text]
                        clicked ::
                            view.color = self.text
                            dialog.show = False
                    Button:
                        flat = True 
                        text = "blue"
                        text_color = COLORS[self.text]
                        clicked ::
                            view.color = self.text
                            dialog.show = False

enamldef ContentView(ViewPager): view:
    transition = 'draw_from_back'
    background_color = "#000"
    paging_enabled = False
    activated::
        app.observe('back_pressed',on_back_pressed)
    func on_back_pressed(change):
        change['value']['handled'] = True 
        view.current_index = max(0, view.current_index-1)

    HomeScreen:
        pass
    AndroidDemoScreen:
        pass


And here's the enaml-native version in action.



The enaml-native version doesn't require any xml layout files. I'd also argue that it's easier to get an idea of what is actually going on by looking at the code, however this enaml-native example doesn't implement the sound, vibrate, and notification API's (as these aren't yet implemented in enaml-native).

Internals

From inspecting the chaquopy code I can say it looks a lot like pybridge except that it uses a custom Python/Java api instead of json.  As mentioned before this bridge looks a lot like PyJnius except it has extra code on the Java side to do things like retrieving modules and objects from python.

The build system is easy because it bundles precompiled versions of crystax python for each arch (see https://chaquo.com/maven/com/chaquo/python/target/). It also makes fantastic use of the gradle build system for installing pip dependencies and python.

There are also a few precompiled wheels available for a few python extensions here (such as numpy and ujson).  However any other extension modules will need cross compiled  and I see no documentation on how they suggest to do it.

It looks like the python stdlib, requirements, and app code are all zipped and added to the assets in the apk.



What's interesting however is that they are NOT extracted (that I can see anywhere) so they must be read directly from the zip.  In my own testing this leads to VERY poor performance, but this easily explains how the app size is 10MB less than a typical enaml-native and kivy app.  Python's zipimport uses C so I don't think any Java implementation will be faster.

Also a few of the python extension modules are not included as separate modules (such as pyexpat and unicodedata) so I'm interested to see if a library like tornado will work with Chaquopy.  Edit: These are included under assets/chaquopy/stdlib-native so it should work fine.

Final comments

I'm glad to see more interest and work being done to try and make python available for Android so seeing this project is awesome.

If the goal is to simply reuse some python libraries in a java app with a native look and feel then this is the perfect fit. It requires an in depth knowledge of android to use but is effective if this is your use case.

But if the goal is to be able to build an app entirely with python than I'm a bit skeptical of how well this will perform when it comes to complex layouts (but please prove me wrong!).  When I first created enaml-native, I tried a similar implementation that used PyJnius to create native widgets and implement interfaces required to interact with them. It worked very well for simple layouts and a small number of nodes (<100), however the view tree grew I started to see very bad performance. The JNI was simply too much of a bottleneck (and so is python).

It looks like a lot of work went into this and I'm excited to see how it turns out.  I wish the best for the project and will be actively be checking for progress updates! The gradle integration is by far the best I've ever seen (even amongst react native projects) and is way beyond what I could do, great work! 

Also, if the developer(s) happens to read this, I'd very much love to chat!

Happy coding!

Comments

  1. im glad, you implemented a similar version in enaml-native to let users know they both offer, i must say it looks more like java than python,

    ReplyDelete
  2. Hi, I'm the creator of Chaquopy. I'm really happy to see this review: I think you've given a fair summary of our strengths and weaknesses.

    The only correction I'd like to make is that pyexpat, unicodedata and the other standard native modules are actually included. They're in the APK under assets/chaquopy/stdlib-native, and you should be able to import and test them in the demo app console.

    I think there's room for many different approaches to Python on Android. Chaquopy focuses on giving complete access to all the features of the standard Android API and build tools. (For example, the XML layout file you mentioned was generated using Android Studio's WYSIWYG editor: it didn't have to be written by hand.) But if you want something more Pythonic, or portable to other platforms, then enaml-native or Kivy is the way to go.

    Thanks again for the great article, and if you want to chat, please drop me an email at the address on the Chaquopy website.

    ReplyDelete
    Replies
    1. Appreciate the feedback, updated the post. Will send you an email. Thanks!

      Delete
  3. It's amazing that you two guys have created such great works.
    I use react-native to build an app. It's convenient to do what i want to do. But the only thing make me give up RN is the speed. Although facebook says RN is very fast like native apps, but it't not. Since the communication between JavaScript and Java is too slow, especially when I use their Navigator and FlatList/SectionList.
    I readed many enaml-native source codes, and EN use jni/thread to do the communication too. So, I doubt if the same things(e.g. performance) will happen in EN.

    ReplyDelete
    Replies
    1. Thanks for the feedback. Yes, EN uses the same approach as RN just with msgpack instead of json. Unfortunately there's no way around this, there's always a trade off. For sectons of an app that need to be very performant (ex a complex ListView) i'd recommend just writing the view in java and then exposing the widget to enaml-native as a native component. This way you get 100% native performance for that widget but still can use the high level api's provided by enaml-native for the other parts of the app.

      Delete
    2. Thans for the replying.
      It's great if EN can use(inject/import) native component directly.
      I'll try that.
      I'll also try to find if there's a same way in RN.
      Thanks!

      Delete

Post a Comment

Popular posts from this blog

Kivy vs React-Native for building cross platform mobile apps

I've built three apps now using Kivy and one with React-Native, just wanted to share my thoughts on both.

Just a warning, I am strongly biased towards python and this is all based on opinion and experience and is thus worth what you pay for it. I don't claim to be an expert in either of these, just have worked with each for several months.  If something is incorrect I'd love to hear advice.

Kivy
Demo of one of the apps



Pros:
Nice to be able to run natively on the desktop WITHOUT a simulatorPython is easy to work withUse (almost) any python libraryVery easy to create custom widgetsKivy properties and data binding just work. Way nicer than React's "state" / flux / redux whatever you want to call it (stupid?). Native interfaces (pyjnius) and (pyobjc)Runs and feels pretty smooth Cons:Default widget toolkit looks like Android 4.4. Requiring you use your own widgets or a theming kit like KivyMD if styling bothers youCreating dynamic widgets declaratively is not yet s…

Control Systems in Python - Part 1 - Bode and Step Response

I hate matlab with passion, yet sadly, nearly everyone uses it.  I'm a fan of Python and open source stuff so here's a simple article on how to do some common control systems stuff in Python.

First we need to make sure the environment is setup.
Install IPython (or you can use any other python shell, but a unicode supported shell is preferred)Install python-control (numpy, scipy)Install sympy
These should do if your on Ubuntu/debian:

sudo apt-get install python-sympy python-numpy python-scipy python-matplotlib ipython
Then you need to install python control, see How to download and install python-control
Intro to using SympyOpen ipython and run the following:

import sympy from sympy import * sympy.init_printing() s = Symbol('s')

Now we can do things like define transfer functions using the symbolic variable s.


We can expand the bottom using the .simplify() method

and we can do something more complex like...
which is really nice because it does all the multiplication for us... and it’…