Transcript
Valkhof: Everyone uses apps on their machines that don't feel quite right. Maybe the mouse works slightly differently, or menus just don't look the same, or maybe you can't really articulate what's wrong with the app. It just feels off. There are a million tiny things that can make an app feel off, regardless of the framework it's built in, but if you know what to look for, you can make your app feel great on all platforms.
Before we get started, I want to spend a few minutes getting everyone up to speed with what Electron is and what it does. Electron is a framework to create cross-platform apps using web technologies. It came out of GitHub, where it was developed for their code editor, Atom. Electron combines Chromium, Node, and a set of operating system specific APIs. In essence, they combine the power and freedom of the developments that we can do on the web with the right APIs to interact with the operating system, with the file system, with notifications, and things like that. What Electron does is not new per se. We've been doing web technology-based desktop software since around 2008, using things like Adobe AIR, but the reason I think Electron has seen such widespread adoption in the past years is that it gets a lot of the stuff right.
Building and packaging apps for Electron is straightforward. It's easy to do, you can even do it cross-platform. The chosen abstractions make it easy to port web applications over to the desktop. Whereas previous iterations of frameworks using web technologies had their own weird version of WebKit or some other rendering engine, with Electron, you know the exact version of Chromium you're going to get, so you get a modern platform to work with and it's not a moving target either. The version you ship with is the version you use.
The nice thing is that the skillset you already have for developing for the web, you can apply that directly into creating Electron apps. What does that look like? Here's an example of the code you need to write to get the QCon schedule up on your desktop as an app. As you can see, it's not that many lines of code, and I'll quickly walk through basically what it does.
We begin with importing two APIs from Electron, the app API, which is basically the main process of your application, and the BrowserWindow API, which we use to create windows. When the app emits the ready event, which is when everything is loaded and is in memory, we can start creating windows. We do that with the BrowserWindow API, and we can give it a few properties. Right now, I'm just giving it a width and a height, and then we can load a URL. Basically, that's all we need to display a webpage as an app on your desktop. You can compound this and run it on all three platforms.
Now, back to this idea of cross-platform applications. We all know that different operating systems have pretty different interfaces and pretty different defaults. The good and the bad news is that the devil is in the details when it comes to this. There's good news because it means that if you want to get it right, you don't need to build three separate apps using three separate APIs and build everything in native widgets for each platform specifically. You can use your own style and you can use your own branding in your app as long as you get some of the basics right. I think that is because web apps and mobile apps have been around for a while now. Because of web apps and mobile devices, users have grown more tolerant and welcoming to different types of interfaces, and we as desktop developers now get the benefits of that.
To illustrate that, here are two email clients. They're about 13 years apart, they're both built using web technologies. On the left is obviously Gmail, and on the right is Superhuman. Superhuman is an email client that runs in Electron. If we look at Gmail, imagine if Gmail was launched as an app looking like this. It would have been disregarded in an instant. People would have gone back to Mail and Outlook, and nobody would know Gmail. Nowadays, an app like Superhuman can launch using the same technologies and the same sets of principles, and it works. People accept it, it has a fully custom UI, not using native widgets, but people know how to work with it because of web apps and mobile apps having done so much of the heavy lifting for us.
You do need to pay attention to the details, and this is especially interesting for Electron apps, because they tend to have a much more custom UI than apps built with other frameworks using native widgets. Strangely enough, the fully custom UI that an app like Slack has, for example, is actually a benefit. By creating a consistent interface for Slack across different platforms, it becomes easier to use for users, because they can use the skillset and the knowledge they have of Slack on one platform and apply it to other platforms where they also use the same application. For each platform, however, it will need to take over some of the customs that that particular operating system uses, for example, how it interacts with the file system or how it shows menus.
In this talk, I'll walk you through eight of these design and implementation details that I think matter the most. I'll show you how to think about them and how to solve them through code and design. First off, a little about me. My name is Kilian Valkhof, and for the past 20 years, I've developed websites, web applications, and desktop applications. I'm also part of the Electron governance team, which oversees the developments of the Electron framework. Last year I started a new company with the goal of making software that improves the lives of developers and designers. The main two products that I work on are Polypane, which is a browser to help developers and designers create websites faster and better, and Superposition, which is an app that lets people kickstart their design system efforts.
For the past 10 or so years, I've used a number of different technologies to publish desktop applications. I've used Qt, which is a cross-platform framework, I've used GTK, which is also theoretically a cross-platform framework. Before I move to Electron, these apps were just theoretically cross-platform. There was nothing much in the code itself of these two apps that prevented them from being run on Mac or Windows, but the process of building and packaging, especially if I wasn't on the platform to build and compile for, was so opaque that I just couldn't do it. They ended up being, in this case, Linux-only apps.
That changed when I discovered Electron. Suddenly, it was really easy for me to distribute apps on all platforms, and I've created dozens of Electron apps open-source and for clients alike. With Electron, you can even cross-compile apps without a problem. If you have a Mac, you can very easily build the software for Linux and Windows as well, right on your machine. It's incredibly easy to create cross-platform applications. If you do that, there are some stuff you need to be aware of. Here's eight ways to make your Electron app feel great on all platforms.
Opening Your App
Let's start with opening your app. We all know what it's like to load a webpage. You stare at a white page for a while, and then things start popping in. After some time, things stop moving, and everything's loaded, and you can use the webpage. We also all know how loading an app works. The icon bounces in your dock for a while, and then the app pops into view fully formed. Because Electron essentially loads a webpage, it will default to the former. It will show you a white window while the page loads, and then you can interact with it. To get an app that feels right, we need to do the latter. We need to hide the window until the page has fully loaded, and only then show it.
If we go back to the QuickStart example, what's happening here is that it renders the window. We say mainWindow is new BrowserWindow, it shows that window immediately, and then we tell it to load a URL. It'll show the window and then start loading a URL. What we actually want to do is flip that around. We want to wait for showing the app until the page we're showing has loaded, and Electron gives us an event to do that called ready-to-show. When we create a new window using the BrowserWindow API, we initially hide it with show as false. Then we wait for the ready-to-show event, which is emitted after the page has fully loaded, and only then do we show the window. This guarantees your page is loaded before the window is shown, so as soon as the user sees your window, it's an app. There's no more loading, there's no more shifting of UI, it's there. The other thing we want to do here is, once we show the window, we want to focus it. This is something that native windows also do. When you show the window, you focus it so that users can immediately interact with it. It's what they expect from native applications. We can do that with the mainWindow.focus function.
The other thing we want to do is set a specific background color for your window. Webpages show a white loading page or show a white page while they load, but your app maybe doesn't have a white background. If it doesn't have a white background, it's especially jarring if users end up looking at a white square or a white rectangle while your app is busy loading. What you want to do is give your browser window specific background color, and it will always default to that color instead, so either when you're loading a page or if you're in between loading different pages within your app. If your app takes a while to load, and you want to make it feel faster, you wouldn't want to wait for the app to fully load it before showing your window. Your user might be waiting a few seconds and think nothing is happening.
In that case, you'll actually want to show the window immediately, provide it with a background color that works for your app. This means that the user already sees something, and once you have that, you can either animate in your UI or show some skeleton screen. What this means is that while the time to using your app might be the same or actually slightly longer, because you're putting animation in front of it, it'll feel faster to your users. Depending on what you want to do with your app and depending on how long it takes to load your app, you either want to hide it until it's ready to show or show it immediately and have a nice loading interface or skeleton screen that fits with your app so you don't want to show a white rectangle. That's what you want to do when opening your app.
Closing Your App
Now, closing your app. I admit we're skipping over some stuff in the middle, but closing your app or the way your app closes is just as important as the way your app opens. It's here where something slightly different happens on Windows and Linux versus Mac, and this is how the platforms work conceptually. On Mac, the app has windows. Windows are parts of the app, but the app itself is something separate. On Windows and on Linux, the app is the window. When you're on Windows and Linux and you close the window, you actually close the app. On Mac, the app will stay running in your dock, and users expect to be able to click on your dock icon and relaunch the app. On Windows and Linux, everything's fairly easy. If your window is closed or when your browser windows are closed, the app will receive this event called window-all-closed. You can safely quit the app and you're done.
We want to prevent that from happening on Mac. What we do here is we check the platform, and if it's darwin, which is the platform name for macOS, we prevent it from quitting, but that only brings us halfway there. While the app hasn't quit, clicking the dock icon doesn't do anything, so it's not that useful. What we want to do for that is we really need to retool our example a bit. What I did here is I extracted the window creation code into its own function, createWindow, and I call that when the app is ready. That's just the same as in the previous example. However, when we close the main window, we clear its reference, and then if a user clicks on the dock icon in Mac, the activate event gets emitted. There, we do a quick check if there already is a window, if there's a main window, and if there isn't, we create it again. We can do that because we extracted the createWindow code into its own function, so we can just call that function again. With this code, we now have an app that closes the way people expect it to close on both Mac, Windows, and Linux.
Remember User Preferences
Now, on to remembering user preferences. On the web, of course, we save user preferences. Users log in, they can do stuff with their settings if you have a web application, but all of them tend to be app-specific user preferences. On desktop, however, we also have these meta user preferences, and they're not things you might think of if you come from a web development background. They're really important for not frustrating your user. Two important things in that regard are remembering the window position or the window geometry and remembering last opened folders.
Once you start user testing your desktop app, you'll find out that nearly each user has their own preference for where their app is on the screen and which dimensions it has. If a user has to reset that every time they open your app, they're going to move on to a different app that does conform to their user preferences. Keeping track of your window position is a really nice thing to do. There are a number of variables you want to keep track of. There's window dimensions and window position, which together is called window geometry. There's which monitor it's being shown on, and also if the app is maximized or not. That last one is actually pretty tricky. It's tricky because maximized is a state that your app can be in, and if you exit the maximized state, native apps will actually restore the browser window or the window to the previously set user geometry. While you should save the fact that your app is maximized, when your app is maximized, you shouldn't save the geometry for that state. Otherwise, people will unmaximize, and nothing will change, because you've set the dimensions to be the size of the maximized window.
You can do that yourself in code, it's not that hard. Electron has events for the resize and move events, and you can get the window geometry with the mainWindow.getBounds function. To deal with the maximized state, however, there's two things we want to do. First, we want to record if the app is in a maximized state, which we can find out with the mainWindow.isMaximized, and then we only save the window geometry if the app is not maximized. We want to store this, and you can store it just in a flat file if you want, but you can also use a package called electron-settings for this, which is what I'm doing here. It's a very simple API, you have settings.set and settings.get. Then once you do that, every time a user moves or resizes their app window, we store the new geometry, the new position.
What we don't want to do is restore the window position on load. Again, we get the window state from our settings, and we set it to the new browser window that we want to create. Of course, you don't always have a window geometry. For example, when the user first launches an app, they've never moved the window before, so you don't have that data. You need to make sure to provide adequate fallbacks.
Then there's a small gotcha. You can't start a window in its maximized position. You can only maximize a window that's already on the screen. What we want to do here is check if it should be maximized only in the ready-to-show event after we've shown the window, which is what we do here. As you can see, this is a whole bunch of codes, but there's an alternative you can use in electron-window-state that will do most of the heavy lifting for you. It's an npm package you can use.
The other thing you want to keep track of if your app supports loading or saving files is you want to keep track of the last used folder. I use a GUI diffing tool quite often, and it's an app with basically two Open File buttons. When I've navigated my file system in the first button, which is often 10 levels deep, because I'm a developer and file system tends to be many folders, I get to do it all over again in the second button, because it didn't remember where I last was. It frustrates me to no end. The nice thing here to do is similar to the window positioning. Let the user continue where they left off. If I navigated to a folder to select something, there's a high chance I want to use that folder again the next time I do the same action, like saving or opening a file. Navigating to that folder when opening a dialog saves a lot of time for your user.
What you want to do as the developer is, on each successful interaction with the file system, and here, we use the Electron showSaveDialog API. On each successful interaction, we want to store the path that the user ended up using, and next time, for the same interaction, we start with that path. That path has the highest chance of being the correct path or, at the very least, it's better than just dumping them back into their home directory.
I mentioned storing this path on successful interactions. You don't want to store the path if a user ended up canceling the interaction, because obviously, what they wanted to find wasn't on that particular path. What we do here in the save dialog where people select a specific file name, in the callback function which starts here at filename, we check if a filename is being set, and only then do we store the path of the file. When we create the window, we can give it a default path, which is the folder to show when it initially opens. Again, here, we use the electron-settings package to do just that.
OS – Specific Menu
A couple of versions ago, Electron did not ship with a default application menu, and particularly on macOS, this gives some issues. On macOS, if an application didn't have an application menu with at least Cut, Copy, and Paste, those actions wouldn't be available in your applications, so you couldn't cut, copy, or paste texts. Guess who found that out after using a note-taking application - this guy. Luckily, nowadays, Electron will give you a default menu with those actions if you don't set one yourself. They solved that issue, but the default menu is pretty Mac-centric. To supply menus that also make sense for Windows and Linux, where there's a File menu instead of an app name menu and the Help menu generally doesn't have things like Search, you, right now, have to replicate the entire menu structure for all three different platforms.
To solve this, I made an npm package called electron-create-menu, and it replaces the menu API that Electron gives you, and by default, it will return a platform-appropriate menu. This means that regardless of the platform you're on or the platform your user is on, it will give you a menu that fits with what they expect from it. Additionally, it gives you a few extra tools in the menu creation that makes it easier for you to create a menu that works on all three platforms. Whereas I previously mentioned that if you wanted to create a menu for all three platforms, you would need to create three menu structures, one for each platform. If you use electron-create-menu, we add a few options to these menu templates that make it easy for you to tell Electron when to show a particular item or on which platform to show a particular item in your menu. That way, you can have a single menu structure and still provide the adequate menus for each platform.
Here's a little snippet of what that would look like. Here's a menu template, and I'm showing the first menu item. On Mac, it's the name of the app, so we do app.getName. On Windows, it's File, so we do a label as File. Two options that I added here are showOn and hideOn, and these take an array of platform names. What we do here is, the top object is only shown on darwin, so on Mac, and the bottom object is always hidden on Mac. This means that Mac users get the top menu, which makes sense for them, and it has all these options that Mac users expect from your app, and the bottom one is sent to Windows and Linux users who apparently only need to quit your app.
Text Highlighting
On to number five, text highlighting. If you press Cmd + A or Ctrl + A on the web, this is what it looks like. The entire page is selected, or the entire page is highlighted. However, if you do the same in a native app, like say, pages, you'll notice that the text selection and the text highlighting is only contained to the actual writable area that's currently focused. What we want to do is more like this and less like the previous slide, and we can do that using CSS. There's a CSS option called user-select, and if we set it to none, the browser will prevent the user from selecting your text. This is nice because if a user then clicks and drags in your app, they won't end up with a bunch of highlighted UI items. You might think that if you add this, then your entire app becomes unselectable, unhighlightable, but Chromium already takes care of making sure that this is unset in text areas and input fields. Any text that the user can type, they can also select and highlight.
Context Menus
Not every application needs a context menu, it's the menu that shows when you right-click somewhere, but it is something that people expect. Because context menus are context-dependent, Electron doesn't give you by default, but people do expect them to be there, especially in things like text areas where they'll want to right-click to cut or copy or paste texts. Electron gives us an event to deal with this. It's the context-menu event, and you can respond to that by creating a menu that fits whatever context the user right-clicked in. This is what you would do. On context-menu, you can create a menu template and then show that as a popup.
For texts, what you want to do is quite a big list, but the nice thing is, in context-menu, you can receive properties - props - and they will tell you what you right-clicked on. If you right-click on something that is editable, like a text area or an input, you can provide the adequate options for texts, like Undo, Redo, Cut, Copy, and Paste. If, say, you're right-clicking on a link, Electron will tell you that it's a link and that it has a URL. If you right-click on a link, you can provide options like open the link or copy the link location. You get quite a bit of information on a right-click, so alongside knowing if what you right-clicked is editable or if it's a link, you can also get the link text, get the selected text, and you can even detect if you're clicking an image or a video and interact with that. Spend some time thinking about, "What are the interactions in my app that could use a context menu?" You could also choose not to do that. In that case, at least add this npm package called electron-context-menu, which will add some basic context menus for your app, for texts, images, and links.
Keyboard Shortcuts
On to keyboard shortcuts. Now that we're seeing more cross-platform apps, you can actually see keyboard shortcuts converge a little. Where previously, on Windows, we would do Alt + D to select a location, now it's all Ctrl + L, just like on Mac, it's Cmd + L. There remains one big difference, and that is keyboard shortcuts on Mac tend to use Cmd, keyboard shortcuts on Windows and Linux tend to use Ctrl. Shortcuts in Electron are created as global shortcuts, and this means they work everywhere regardless of whether your app is focused or not, and more on that in a second. The keyboard shortcuts that you fill in here are written out as a string so they're pretty readable. You can just write backspace or Alt + R, and those will work. These are global shortcuts, so they even work if your app isn't focused, and usually, that's not what you want. Usually, you just want your shortcuts to work when you're app is focused, when people are interacting with your app.
There are two options for this if you want what we call local shortcuts, so shortcuts that only work in your app. The first one is, use a JavaScript library like Mousetrap in your browser window. This is for a JavaScript-based solution. There are some benefits to using global shortcuts over the JavaScript solution, primarily that global shortcuts support more keys. Because a browser has some security concerns, it won't actually let you detect if a user uses function keys, for example. If you want to have a Help function under the F1 key, then you'll want to use global shortcuts to offer that. If you want to support more keys like this, but you want to make them behave like local shortcuts, then there's an Electron package called electron-local-shortcuts, which will do this for you using global shortcuts. The trick it does here is whenever your app is focused, it will create the shortcuts; whenever your app is out of focus, it will remove shortcuts again and release it back to the operating system.
For the shortcuts themselves, say you wanted to create a Save shortcut and you're already convinced that you need to offer something different for Mac and for Windows, because they use Cmd instead of Ctrl. Would you do it like this, where you basically register two shortcuts and have some code duplication? You could, but Electron actually gives us a very nice way of doing this. You can type Cmd or Ctrl for your shortcuts, and Electron will then decide which of these two to use depending on the platform that you're running the app on. This will save quite a lot of if-statements.
Using System Fonts
Lastly, number eight, using system fonts. To make an app integrate with the system, using the same font is a really powerful way to make it feel cohesive with the rest of your operating system. Unfortunately, the browser font isn't always the same font as the rest of your operating system, especially when that last one is user-customizable. What you could do is to create a huge font stack like this. There's San Francisco for Mac, Segoe UI for Windows, and more of these, but this stack, even though it's already very long, isn't actually complete. It doesn't include the usual Linux system fonts, like Ubuntu Sans for Ubuntu, Oxygen for KDE, or DejaVu Sans for other Linux distributions. That's not cool, but, maybe just adding more and more and more fonts to this list also isn't the best solution, because say you add Ubuntu Sans somewhere, but a Windows user has decided that they actually quite like Ubuntu Sans and they've installed the font. Well, too bad, now you're using Ubuntu Sans in your app on Windows, so it doesn't feel native either.
Luckily, there's a simpler way to do this. You can use font-family: caption. Caption is a special keyword value in CSS that maps to what the operating system uses for captions in native widgets, and that happens to be the default font that is being used everywhere. If you do font-family: caption, you'll get Ubuntu on Ubuntu, Segoe UI on Windows, and San Francisco on Mac. It will always fit without you needing to be explicit about it. If you want to use your own fonts, you can also do that, of course, and it can have a great effect. For example, Slack uses the font Lato on all their platforms, and then that makes it feel more cohesive inside the app. If you want to do that, it's best to ship the fonts with your app rather than using something like Google Fonts, because you don't want to depend on network connectivity for your app to look the way you want it to look. You can use either a woff or a ttf right in your Electron app.
Memory
That's my eight tips, and I'll review them in a second, but I can't give a talk about Electron without mentioning memory. Personally, I don't think this is a huge problem. Sure, your average user is going to bulk as using 100 megabytes to run an app, but really, it's not significantly higher than most other GUI-heavy apps. The problem that you can run into though is memory leaks, and if you can from the web, you really only need to care about the worst of memory leaks, because webpages tend to be relatively short-lived. This changes a little if you are working on an SPA, but even SPAs tend to have full-page refreshes every now and then. Apps are much longer lived, and because of that, small memory leaks can also become issues. Let's check out some strategies in dealing with that.
Before I start, I want to mention that Electron has excellent performance documentation at this link, and it focused mostly on making your app start fast and feel snappy by making sure that you're not loading unnecessary polyfills, because there's a very modern Chromium that you use by loading code strategically and by bundling your code. It's an excellent resource, and you definitely should check it out.
For memory leaks, we get the benefit of running Chrome and using Chrome's Developer Tools. In recent years, the Chrome Developer Tools have had really good performance tooling, and you can use that right in Electron to suss out any memory issues in your application. What you want to do is go to the Performance tab in your Developer Tools, make sure that memory is checked, and start the recording by clicking the Record button. At this point, use your app either as you normally use it, just going through your app, or focus in on a specific functionality of your app that you want to test. Then after a while, you can stop the recording and you wind up with something that looks like this.
If you've never seen this before, it's a little bit intimidating. It's really too much to go into all of this for this presentation, so I'll zoom in on a small part. What we want to focus on is the graph at the bottom here. Right now, I've checked the JS Heap, which is your memory usage, and the Listeners, which is the number of your JavaScript listeners. If both of these go up and up, then there's an issue. There's a memory leak, and most memory leaks are you not cleaning your listeners after you're done using them. This will tell you where that is happening. Additionally, the chart on the top of this slide, which is called a flame graph, and it looks really intimidating, actually gives you some really nice information on what your app is doing and what functions are causing slowdowns. It will tell you which functions are slow, because it will show the red triangles in the corner, and then it will actually show you which function calls which function that is taking such a long time. This is a really nice way to pinpoint, "What are the events that I should be looking at to improve my app's performance?"
If we want to do this for Node, we can also do that using the exact same methodology, because we can actually start Electron with --inspect, and if we're not in a Chrome browser, go to chrome://inspect. We can select the Electron instance, and we can do the exact same performance testing for the Node side of your Electron application.
In Conclusion
To conclude, building a cross-platform app that feels great everywhere doesn't require conforming to the platform UI. Thanks to web apps and mobile devices, people are more familiar with different interfaces, but the devil is in the details. To make your app feel at home, you need to take care of at least these things.
One, you need to not launch your app like a site. You want to hide it until it's ready to show or you want to use a fitting background color that matches with the style of your app. Two, you want to handle window closing like the OS. On Mac, you want to keep your app running and reopen it whenever a user clicks your icon. On Windows and Linux, closing the last window should also close the app. Number three, remember user preferences. Make sure that if a user reopens your application, restore the window to their last position and size. Make sure to remember the last used folder if you provide saving or opening functionality in your app. Four, make sure to provide a menu that fits their operating system, and the easiest way to do that is use electron-create-menu. Number five, prevent text highlighting. Use this CSS to prevent users from accidentally dragging your app and having a bunch of your UI selected. Number six, provide context menus where they make sense, so right-clicking on text should have cut, copy, paste, etc., and use the contextual information you get for a context menu to give extra options for things like images and links. Number seven, provide operating system specific keyboard shortcuts, and the easiest way to do that, use Cmd or Ctrl. Number eight, use the system font. With font-family: caption, you'll always get the fonts that your operating system uses by default.
Lastly, keep your memory leaks under control. These are the ingredients to make your app feel at home on all platforms. That was my talk. Check out electronjs.org for more information on Electron. My name is Kilian Valkhof, and you can find me at these links. Make sure to check out Polypane.
Questions and Answers
Participant 1: When you're developing your Electron apps, is there an easy way? Because you're looking at Dev Tools to see what's taking a long time to run, but is there something that shows you how much memory is being used? Is it easier to detect a memory leak?
Valkhof: That's actually quite difficult, because Electron uses Chromium. One of the nice features that Chromium gives you is that it splits up the app in multiple helpers. The best way to detect memory leaks is using the Chrome Dev Tools for this rather than, for example, looking at your system monitor, because those numbers don't really reflect what your app is really using.
Participant 2: What do you use for the automated testing? Is there anything like Karma or whatever that the web browser people use? Do you personally use any of these?
Valkhof: Because Electron is just Chrome and Node, you can use the same testing that you would use for your Nodes application and for your browser application. I believe there are hooks for Puppeteer, etc. that you can use to orchestrate your phone application. Basically, you run a web app in Electron, and you can test that web app using any of the JavaScript testing libraries.
See more presentations with transcripts