⬆️ ⬇️

Developing a game for Android on Python based on Kivy. From A to Z: pitfalls and non-obvious solutions. Part 1

Some time ago I decided to try to write something in Python under Android. Such a strange choice for many is due to the fact that I love Python and I love Android, and I also like to do something unusual (well, not the most usual ). Kivy was chosen as the framework - in fact, the only alternative, but I really liked it. However, there is not too much information on it (no, the documentation is excellent, but sometimes it is not enough), especially in Russian, and although some things can be implemented, they either haven't done them before, or have not considered it necessary to share information . Well, I thought :) And this post is the result.



Under the cut, I will talk about all the stages of development, how a simple idea developed and how to do this, we had to look for new opportunities, new pitfalls and bugs, unclear solutions and outdated documentation :) The goal is to describe the main points in one text. the person who decided to write something a little more difficult than playing Pong from the official tutorial did not have to search through the official support forum and StackOverflow and spend hours on what is being done in a couple of minutes if you know how.



0. If for the first time you hear about Kivy ...



... it all depends on whether you like Python and Android, and whether you are interested in understanding this. If not, it is easier to score :) And if yes, then you need to start with the official documentation , guides , and the already mentioned official tutorial on the game Pong - this will give a basic understanding of the framework and its capabilities. I will not dwell on such trivial things (especially since the tutorial is great for understanding the basic principles) and I will go further. We assume that it was an introduction :)



1. A little about my game



First you needed an idea. I wanted something simple enough to appreciate the capabilities of the framework, but also quite interesting and original, so as not to program for the sake of programming (this is great, but when it’s not the only goal, it’s even better). I design interfaces well, but I don’t know how to draw, so the game had to be simple graphically, or even text-based. And it just so happened that I had an abandoned website with quotes from which I once started my way in web development (I even wrote about it on Habré many years ago). Therefore, the idea arose like this: a quiz game "Guess the quote". In the Russian-language Google Play there was nothing of the kind, and in English there was a pair of low-quality crafts with hundreds of downloads.

')

Almost immediately it became clear that just to guess the quote after quote was boring. So the first "chips" appeared, which, as a result, determined the final game. First of all, these were thematic packages (that is, packages of quotes combined by one theme or author) and points (which are charged for guessing quotes and passing packages, and are spent on tips and unlocking new topics), as well as statistics, achievements and favorites.



Some pictures
So it all began (clickable):





Well, okay, okay, I will not show such horror anymore :) By the way, this is how it looks now (also clickable, screenshots are taken from Google Play):



The first problems started from the first screen ...



2. Kivy brake or am I doing something wrong?



A friend of mine likes to answer such “yes” questions :) In fact, some things in Kivy are really slow, for example, creating widgets. But this does not mean that this matter cannot be optimized. I'll tell you about it.



Since quotes and topics are stored in the database, then, of course, the buttons with packages are generated dynamically. And it was here that I discovered that this is happening very slowly: about half a second per list of 20 buttons. Perhaps this is not very much when downloading the application, but when switching to the main screen from other internal screens of the application is inadmissibly a lot. It is worth noting that by that time the button was, in fact, a set of several elements that visually constitute one button:







My first impulse was to cache them in one way or another, and, indeed, experience showed that if you create all widgets in advance and save them as a property of the StartScreen object, then everything (except the first generation) works fairly quickly. However, the data in the buttons must be periodically updated (at least the same number of quotations that have been guessed). Yes, and I already planned to download new packages. Of course, it’s not a problem to realize this, but I decided not to reinvent the wheel and think.



At first, it was worth making sure that the problem was in the creation of widgets, so in a few minutes I sketched a simple application on two screens, each of which generated a set of lines from the label and the checkbox of 50 pieces. :)



Test application source code
main.py:

from kivy.app import App from kivy.uix.screenmanager import ScreenManager, Screen from kivy.uix.boxlayout import BoxLayout from kivy.properties import ObjectProperty, StringProperty from kivy.clock import Clock from time import time class ListScreen(Screen): items_box = ObjectProperty(None) def on_enter(self): start = time() for i in range(0,50): self.items_box.add_widget(ListItem('Item '+str(i))) self.items_box.bind(minimum_height=self.items_box.setter('height')) print time()-start def on_leave(self): self.items_box.clear_widgets() class ListItem(BoxLayout): title = StringProperty('') def __init__(self, title, **kwargs): super(ListItem, self).__init__(**kwargs) self.title = title class ListApp(App): sm = ScreenManager() screens = {} def build(self): self.__create_screens() ListApp.sm.add_widget(ListApp.screens['list1']) Clock.schedule_interval(self._switch, 1) return ListApp.sm def _switch(self, *args): ListApp.sm.switch_to(ListApp.screens['list1' if ListApp.sm.current != 'list1' else 'list2']) def __create_screens(self): ListApp.screens['list1'] = ListScreen(name='list1') ListApp.screens['list2'] = ListScreen(name='list2') if __name__ == '__main__': ListApp().run() 


list.kv:



 <ListScreen>: items_box: items_box BoxLayout: orientation: "vertical" AnchorLayout: size_hint_y: 0.1 padding: self.width*0.1, self.height*0.05 Label: font_size: root.height*0.05 text: "Some list" ScrollView: size_hint_y: 0.9 size: self.size BoxLayout: id: items_box orientation: "vertical" padding: self.width*0.1, 0 size_hint_y: None <ListItem>: orientation: "horizontal" size_hint_y: None height: app.sm.height*0.1 Label: font_size: app.sm.height*0.025 text: root.title size_hint_x: 0.9 text_size: self.size valign: "middle" CheckBox size_hint_x: 0.1 


I launched it on my old Moto G (gen3) and got:



 11-28 11:44:09.525 1848 2044 I python : 0.5793800354 11-28 11:44:10.853 1848 2044 I python : 0.453143119812 11-28 11:44:12.544 1848 2044 I python : 0.633069992065 11-28 11:44:13.697 1848 2044 I python : 0.369570970535 11-28 11:44:14.988 1848 2044 I python : 0.594089031219 


And further in the same vein. Search on this issue did not give anything, so I turned to the developers. And he received the answer: “Creating widgets is relatively slow, especially depending on what they contain. It’s better to use RecycleView to create large lists. ” Here I want to explain why I generally describe this moment, because the description of RecycleView is in the documentation . Yes, indeed, there is, but few people are able to study and remember all the documentation before starting development, and finding the right tool is not easy, especially if it is not described anywhere in the context of solving a specific problem. Now he is described :)



Test application source code with RecycleView
main.py:

 from kivy.app import App from kivy.uix.screenmanager import ScreenManager, Screen from kivy.properties import ObjectProperty from kivy.clock import Clock from time import time class ListScreen(Screen): recycle_view = ObjectProperty(None) items_box = ObjectProperty(None) def on_enter(self): start = time() for i in range(0,50): self.recycle_view.data.append({'title': 'item'+str(i)}) print time()-start def on_leave(self): self.recycle_view.data = [] class ListApp(App): sm = ScreenManager() screens = {} def build(self): self.__create_screens() ListApp.sm.add_widget(ListApp.screens['list1']) Clock.schedule_interval(self._switch, 1) return ListApp.sm def _switch(self, *args): ListApp.sm.switch_to(ListApp.screens['list1' if ListApp.sm.current != 'list1' else 'list2']) def __create_screens(self): ListApp.screens['list1'] = ListScreen(name='list1') ListApp.screens['list2'] = ListScreen(name='list2') if __name__ == '__main__': ListApp().run() 


list.kv:

 <ListScreen>: recycle_view: recycle_view items_box: items_box BoxLayout: orientation: "vertical" AnchorLayout: size_hint_y: 0.1 padding: self.width*0.1, self.height*0.05 Label: font_size: root.height*0.05 text: "Some list" RecycleView: id: recycle_view size_hint: 1, 0.9 viewclass: "ListItem" RecycleBoxLayout: id: items_box orientation: "vertical" padding: self.width*0.1, 0 default_size_hint: 1, None size_hint: 1, None height: self.minimum_height <ListItem@BoxLayout>: orientation: "horizontal" size_hint: 1, None height: app.sm.height*0.1 title: '' Label: font_size: app.sm.height*0.025 text: root.title size_hint_x: 0.9 text_size: self.size valign: "middle" CheckBox size_hint_x: 0.1 


 11-29 13:11:58.196 13121 13203 I python : 0.00388479232788 11-29 13:11:59.192 13121 13203 I python : 0.00648307800293 11-29 13:12:00.189 13121 13203 I python : 0.00288391113281 11-29 13:12:01.189 13121 13203 I python : 0.00324606895447 11-29 13:12:03.188 13121 13203 I python : 0.00265002250671 


More than 100 times faster. Impressive, isn't it?



In conclusion, it should be mentioned that RecycleView is not a panacea. It is not suitable if the size of the element depends on the content (for example, Label, the size of which varies depending on the amount of text).



3. Services. Autorun and restart



The next problem I encountered did not give in to the solution for so long that I had cowardly thought to consider this framework unusable and score :) The problem was with the services (in Android this is the name of the processes running in the background). Creating a service is not so difficult - a little confusing outdated documentation, but nothing more. However, in most cases, is there a lot of confusion from the service, which, firstly, does not start automatically when the phone is loaded, and secondly, it does not restart if the application is “thrown away” with a swipe from the task manager? In my opinion, no.



At that time, there was only one article on this topic in the official wiki , but even though it was called “Starting Kivy service on bootup,” it really only told how to start the application when loading the phone, but not its service (yes, this is also useful, but much less often, as for me). In the end, I rewrote that article, and here I will tell you the details.



Suppose we have a primitive service, which just does something that periodically displays a string in the log (this we eliminate in advance the bugs that may occur due to the nature of the service itself).



 from time import sleep if __name__ == '__main__': while True: print "myapp service" sleep(5) 


From the application, we run it with the main class method using PyJnius :



 from jnius import autoclass # ... # class GuessTheQuoteApp(App): # ... # def __start_service(self): service = autoclass('com.madhattersoft.guessthequote.ServiceGuessthequoteservice') mActivity = autoclass('org.kivy.android.PythonActivity').mActivity service.start(mActivity, "") 


If the APK is built correctly , the service will start when the application starts, but this is not enough.



To begin with, we will try to make it restart when the application is stopped (for example, when removing it from the task manager). Of course, it would be possible to use startForeground , but this is not quite the background task execution :) It will require at least a notification - this is not always suitable. In this case, the START_STICKY flag is perfect, but we write in Python, which makes the task not so trivial - at least, with the help of PyJnius, it is no longer solved.



Honestly, it is generally solved quite crookedly, since I am not yet ready to become one of the developers of Python4Android , thanks to which all this happiness works in general. And changes need to be made to the Python4Android code. Specifically, we need the .buildozer / android / platform / build / dists / guessthequote / src / org / kivy / android / PythonService.java file in which, in the startType () function, we change the START_NOT_STICKY flag to START_STICKY:



 public int startType() { return START_STICKY; } 


Hooray, the service will restart. Everything? Of course not :) Because he immediately collapses with an error:



 E AndroidRuntime: Caused by: java.lang.NullPointerException: Attempt to invoke virtual method 'android.os.Bundle android.content.Intent.getExtras()' on a null object reference 


The problem is in the onStartCommand function (Intent intent, int flags, int startId), because after restarting the intent we are null. Well, we will rewrite it:



 @Override public int onStartCommand(Intent intent, int flags, int startId) { if (pythonThread != null) { Log.v("python service", "service exists, do not start again"); return START_NOT_STICKY; } if (intent != null) { startIntent = intent; Bundle extras = intent.getExtras(); androidPrivate = extras.getString("androidPrivate"); androidArgument = extras.getString("androidArgument"); serviceEntrypoint = extras.getString("serviceEntrypoint"); pythonName = extras.getString("pythonName"); pythonHome = extras.getString("pythonHome"); pythonPath = extras.getString("pythonPath"); pythonServiceArgument = extras.getString("pythonServiceArgument"); pythonThread = new Thread(this); pythonThread.start(); if (canDisplayNotification()) { doStartForeground(extras); } } else { pythonThread = new Thread(this); pythonThread.start(); } return startType(); } 


Alas:



 F DEBUG : Abort message: 'art/runtime/java_vm_ext.cc:410] JNI DETECTED ERROR IN APPLICATION: GetStringUTFChars received NULL jstring' 


The problem is that the nativeStart () function does not get the required Extras. Unfortunately, two of them I had to hardcore. As a result, it looks like this:



 @Override public void run(){ String package_root = getFilesDir().getAbsolutePath(); String app_root = package_root + "/app"; File app_root_file = new File(app_root); PythonUtil.loadLibraries(app_root_file); this.mService = this; if (androidPrivate == null) { androidPrivate = package_root; } if (androidArgument == null) { androidArgument = app_root; } if (serviceEntrypoint == null) { serviceEntrypoint = "./service/main.py"; // hardcoded } if (pythonName == null) { pythonName = "guessthequoteservice"; // hardcoded } if (pythonHome == null) { pythonHome = app_root; } if (pythonPath == null) { pythonPath = package_root; } if (pythonServiceArgument == null) { pythonServiceArgument = app_root+":"+app_root+"/lib"; } nativeStart( androidPrivate, androidArgument, serviceEntrypoint, pythonName, pythonHome, pythonPath, pythonServiceArgument); stopSelf(); } 


That's it.



Let us turn to autostart service when you start the phone. After the previous problem it will be easier. (Actually, everything was the opposite - I couldn’t understand for a very long time what exactly to add, because there was no information about it anywhere at all, and the developers themselves didn’t know how to solve this problem either. And only having figured out parallel with the restart issue, I understood what needs to be done.)



First you need permission RECEIVE_BOOT_COMPLETED - it's easy. And then - BroadcastReceiver, you will have to add it to AndroidManifest manually, but this is also not a problem. The problem is that to write in it :)



The solution for launching an application (not a service) is as follows:



 package com.madhattersoft.guessthequote; import android.content.BroadcastReceiver; import android.content.Intent; import android.content.Context; import org.kivy.android.PythonActivity; public class MyBroadcastReceiver extends BroadcastReceiver { public void onReceive(Context context, Intent intent) { Intent ix = new Intent(context, PythonActivity.class); ix.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startActivity(ix); } } 


At first I tried to just rewrite it for the service:



 package com.madhattersoft.guessthequote; import android.content.BroadcastReceiver; import android.content.Intent; import android.content.Context; import com.madhattersoft.guessthequote.ServiceGuessthequoteservice; public class MyBroadcastReceiver extends BroadcastReceiver { public void onReceive(Context context, Intent intent) { Intent ix = new Intent(context, ServiceGuessthequoteservice.class); ix.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startService(ix); } } 


Aha, overclocked :)



 E AndroidRuntime: java.lang.RuntimeException: Unable to start service com.madhattersoft.guessthequote.ServiceGuessthequoteservice@8c96929 with Intent { cmp=com.madhattersoft.guessthequote/.ServiceGuessthequoteservice }: java.lang.NullPointerException: Attempt to invoke virtual method 'java.lang.String android.os.Bundle.getString(java.lang.String)' on a null object reference 


I think you already understand that the problem is in those Extras. I then had to find out about it from nowhere. But I will not pull, the working code looks like this:



 package import com.madhattersoft.guessthequote; import android.content.BroadcastReceiver; import android.content.Intent; import android.content.Context; import com.madhattersoft.guessthequote.ServiceGuessthequoteservice; public class MyBroadcastReceiver extends BroadcastReceiver { public void onReceive(Context context, Intent intent) { String package_root = context.getFilesDir().getAbsolutePath(); String app_root = package_root + "/app"; Intent ix = new Intent(context, ServiceGuessthequoteservice.class); ix.putExtra("androidPrivate", package_root); ix.putExtra("androidArgument", app_root); ix.putExtra("serviceEntrypoint", "./service/main.py"); ix.putExtra("pythonName", "guessthequoteservice"); ix.putExtra("pythonHome", app_root); ix.putExtra("pythonPath", package_root); ix.putExtra("pythonServiceArgument", app_root+":"+app_root+"/lib"); ix.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); context.startService(ix); } } 


Fuh :)



Localization and multilingualism



In general, you can use gettext for localization, or even easier to do - create the lang folder, in it, for each language (for example, en.py and ru.py) in a file, define all words and phrases there in the form of variables / constants, and then connect the desired module. Like that:



 if autoclass('java.util.Locale').getDefault().getLanguage() in ('ru', 'uk', 'be'): import lang.ru as lang else: import lang.en as lang GuessTheQuote.lang = lang 


The static variable is used to make language constants convenient to use in the kv-file:



 app.lang.some_phrase 


This, in general, is rather trivial, and the main thing I wanted to tell you about in terms of localization is how to set constants in res / values ​​/ strings.xml and individual localizations. Why do you need it? At a minimum, to set the name of the application in different languages, as well as to register constants such as app_id for Google Play services and facebook_app_id for Facebook services.



By default, P4A generates strings.xml as follows:



 <?xml version="1.0" encoding="utf-8"?> <resources> <string name="app_name">Guess The Quote</string> <string name="private_version">1517538478.81</string> <string name="presplash_color">#2EBCB2</string> <string name="urlScheme">kivy</string> </resources> 


In this case, the application name and background color of the boot screen can be set in buildozer.spec. At first glance, this is enough, but this is only if the application is monolingual, and additional string constants are not needed, and this is somehow minimalist :) Of course, no one forbids manually setting everything you need, but with the next build it will be overwritten. You can also manually create folders with localizations, for example, values-ru, but they will not be updated for new builds. Therefore, it is better to once again correct P4A, namely, the .buildozer / android / platform / build / dists / guessthequote / build.py file as follows:



 #  ,    P4A    370 render( 'strings.tmpl.xml', 'res/values/strings.xml', args=args, url_scheme=url_scheme, ) #    :) local_args = {'be': argparse.Namespace(**vars(args)), 'ru': argparse.Namespace(**vars(args)), 'uk': argparse.Namespace(**vars(args))} for key in local_args: local_args[key].name = u' !' #  , ,         P4A  buildozer,       for i in os.listdir('res'): if i[:6] == 'values': render( 'strings.tmpl.xml', 'res/'+i+'/strings.xml', args=(args if i == 'values' else local_args[i[7:10]]), url_scheme=url_scheme, ) #    ,    P4A    388 with open(join(dirname(__file__), 'res', 'values', 'strings.xml')) as fileh: lines = fileh.read() with open(join(dirname(__file__), 'res', 'values', 'strings.xml'), 'w') as fileh: fileh.write(re.sub(r'"private_version">[0-9\.]*<', '"private_version">{}<'.format( str(time.time())), lines)) #       for i in os.listdir('res'): if i[:6] == 'values': with open(join(dirname(__file__), 'res', i, 'strings.xml')) as fileh: lines = fileh.read() with open(join(dirname(__file__), 'res', i, 'strings.xml'), 'w') as fileh: fileh.write(re.sub(r'"private_version">[0-9\.]*<', '"private_version">{}<'.format( str(time.time())), lines)) 


Well, all the string constants you need should be written in the .buildozer / android / platform / build / dists / guessthequote / templates / strings.tmpl.xml file



To be continued



If the article seems interesting to the community, in the second part I will describe the most interesting things: in-app purchases, integration of Google Play Games services and Facebook SDK, and preparation of a release version with subsequent publication on Google Play, and also prepare a project on Github with modules for implementing the described tasks. If you are interested in any other details - write in the comments, I will try to highlight as much as possible.

Source: https://habr.com/ru/post/348226/



All Articles