Understanding Compose (Android Dev Summit '19)

Video Statistics and Information

Video
Captions Word Cloud
Reddit Comments
Captions
[MUSIC PLAYING] LELAND RICHARDSON: Hello, my name is Leland Richardson. I'm a software engineer on the Android UI Tool Kit team. More specifically, I work on the Compose Runtime and the Compose Compiler. So earlier this year, we open-sourced Compose at Google I/O. And since then, we've been developing it out in the open, as part of the Android Open Source Project. As app developers, the expectations around UI development have really grown. Today, we can't really build an app and meet the user's expectations without having a polished user interface, animation, motion. All of these things are things that didn't exist 10 years ago as an expectation from the user. So Compose, we believe, is a modern UI toolkit that really sets app developers up for success in this new landscape. So today, what I want to talk about is, what problems specifically does Compose solve? What were the reasons behind some of our design decisions? And how can that help you as an app developer? Additionally, I want to talk about the mental model of Compose. I want to talk about how you should think about code that you write in Compose-- how you should shape your APIs. And then finally, I want to talk about some of the implementation details and how Compose actually works under the hood and explain what's actually happening here. So let's get started. What problems does Compose solve? And really, to frame this, what I want to talk about today is separation of concerns. Separation of concerns is a well-known software design principle. It's one of the fundamental things that we learn as app developers. And really, it's an age-old kind of thing. More than 40 years ago, when separation of concerns was originally postulated, it was actually framed in terms of two other words-- coupling and cohesion. And so what I want to do today is I want to talk about separation of concerns in terms of this, because I think it's a little bit more concrete and can help us understand exactly what we're talking about. So abstractly, when we write code, we think of our application in terms of modules. And we might think of our module in terms of multiple units. So our application has several of these modules. And between them, we can think of these dependencies as coupling. Basically, there's ways in which parts of one module influence the other. And one way to think of this is-- if I make a change to some code somewhere, how many other changes to other files am I going to have to make? And that's coupling. And in general, what we want to do is we want to reduce coupling as much as possible. And sometimes, coupling is actually implicit. There's a dependency that we're relying on or something that we're relying on that's not actually very clear, but something breaks because of a change that happens somewhere else. On the other hand, we have cohesion. And cohesion is really about how the units inside of a given module belong to one another. They're related to one another. And cohesion is generally seen as a good thing. And so one way to think of this is that separation and concerns is really all about grouping as much related code together as possible so that our code can be maintainable over time and really scale as our application grows. So framing this in terms of something more familiar to you, let's talk about a kind of common situation. We have a view model. Here on the left. And then we also have an XML layout. And the view model is really providing data to this layout. We have a view that we need to populate with something. And it turns out, there's actually a lot of dependencies hidden here. There's a lot of coupling between the view model and the layout. And one of the more familiar ways that you can see this is through findViewByID. What we're doing is we're trying to understand what the XML layout is actually defining, finding specific elements into it, and piping data through it. We might even, more subtly, depend on things that happen in layout XML. We actually might rely on a certain structure that was defined there. And so we have to keep these things in sync as our application grows. And really, our application can grow a lot. These layout XMLs get very, very large. We have very large complicated UIs. And they're dynamic too. So sometimes, one element might leave the view hierarchy at runtime but not statically, and this leads to null reference exceptions and things like that. So one of the fundamental things here is that we have a view model defined in Kotlin. And then our layout XML is defined in XML. And so because of this difference in language, there's actually a forced line of separation here, even though the view model and the layout XML can sometimes be very, very intimately related. In other words, they're very tightly coupled. So what if we started to define the layout, the structure of our UI, in the same language? What if we chose Kotlin? Now because we're in the same language, some of these dependencies might start to become more explicit. And even more, we can start to refactor some code and move things over to where they belong and actually reduce some of that coupling and increase some of the cohesion. Now, some of you might be thinking about what I'm saying here and be a little bit skeptical. Am I saying that we should mix Logic with our UI? Well, here's the thing. As framework authors, we actually can't perfectly separate your concerns for you. This is something that only, really, you can do. You have parts of your logic that will not escape the UI. They are part of your UI. And we actually can't prevent that. But what we can do is provide you with tools to make that separation easier. And so I'm here today to try and convince you that that tools is a Composable function. And actually, this might sound a little bit less controversial than it is. If you take away the Composable part, it's just a function. And a function is something that you've been using, probably for a long time, to separate concerns elsewhere in your code. And the skills that you have acquired to do that type of refactoring and writing reliable, maintainable, clean code-- those same skills apply to Composable functions. So today, I want to talk about the anatomy of a Composable function a little bit and try to help you understand how to think about these things. So here's an example of a Composable function. And it receives data as parameters. We have this app data class that comes in. And we want to think of the parameters that come into a Composable function, really, as a mutable data. It's data that-- the Composable function really shouldn't be changing. We should just be treating it as a transform function of that data. Now because of that, we can use any code that we want to, in Kotlin, to take that data, create, drive data from it and then use that and describe our hierarchy here in this function. And this means that we call other Composable functions. And those invocations represent the UI in our hierarchy. Also, we're able to use all of the language level primitives that Kotlin already has in order to do things dynamically. So we can if statements and for loops for control flow and dealing with the more complicated logic that our UI might have. And then finally, I want to point out here that we're leveraging Kotlin's trailing lambda syntax. So Body here is a composable function that has a composable lambda as a parameter. And that ends up implying some sort of hierarchy or structure. And so Body is something that wraps these set of items here. So you've probably heard us say the word "declarative." "Declarative" is a buzzword. But it's an important one. And I want to describe what we mean by that. And usually, when we talk about declarative, we're talking about it in contrast to imperative programming. So let's look at an example to understand this more. What if we had a UI, like a mail or a chat application, where we have an Unread Messages icon. And so if there are no messages, we render a blank envelope. If there are some messages, we put some paper in it. And maybe we're a little bit cutesy. And if there are over 100 or something, we show some fire and stuff like that. So with an imperative interface, we might write an update count function something like this, where what we do is we get in the new count. And we go through and we figure out how we're supposed to poke at this UI in order to make it reflect the proper state. And actually, there's a lot of corner cases here. And this logic isn't easy, even though it's a relatively simple example. And so if you take this logic and, instead, write it in a declarative interface, you might end up with something like this. And so here what we're doing is we're saying, OK, if the count's over 99, show fire. If the count's over 0, show paper. If the count's over 0, render a badge with this count. And that is what I mean when we talk about a declarative API. And if you want to think about it-- so as a UI developer, the things you need to think about-- one-- given this data, what UI do I want to show? How do I respond to events and make my UI interactive? And then here's the critical thing. We no longer need to think about how our UI changes over time. What happens is, when we get in the data, we show what it should look like. We show what the next state is. And then the framework controls how to get from one state into the other. And so now we no longer need to think about it. And that's the critical piece. So describe the UI based on the provided parameters. And understand that the Composable function, it's one function definition. But it describes all possible states of your UI in one place. It's locally defined. And that leads into what we mean by composition. So with a name like Compose and an annotation called Composable, it seems like composition is an important concept here. So I want to talk more about that. And really, one of the things that we're talking about here is that our model of composition differs from the model of composition that Inheritance follows. And they're both types of compositions. So what we're talking about here is a different type. So let's go through an example for this as well. Let's say we have a view. And we want to create an input. And so we use View as our base class. And then we want a ValidatedInput, and so we create a subclass of input to do that. And we want a DateInput. And we want to use the validation of a date, and so we subclass ValidatedInput here as well. But then we run into a problem. When we want to create a date range input, we have two dates. So we want to validate two dates separately. So maybe we want to subclass DateInput, but there are two of them. So we can't really do that. And so we run into this limitation around inheritance that we have to have one parent that we inherit from. In Compose, the problem is simpler. So when we create our ValidatedInput, we just call Input in the body of our function. And we can decorate it with something for validation. Then when we create a data input, we end up calling ValidatedInput as well. And now, when we run into the date range input, we no longer have a problem. It's just two calls. And so there is no single parent that we compose onto in Compose's composition model. And that solves this problem. Another type of composition problem is what I would call containment. So we want to have this fancy box, which is a view that decorates other views. And we might have some other views here, like Story and EditForm. And then we want to make a fancy story and a fancy edit form. But what do we do? Do we inherit from FancyBox? Or do we inherit from Story? It's unclear because, again, we need one parent for that inheritance chain. And so Compose handles this really well. We have a Composable lambda as children. And that allows us to define something that wraps another thing. So now when we want to create FancyStory, we just call Story inside of the children of FancyBox-- same with FancyEditForm. And this is Compose's composition model. Another thing that Compose accomplishes really well is encapsulation. This is what you should be thinking about when you make public APIs of composable functions. And the public API of a composable is really the set of parameters that it receives. And those are given to it, so it doesn't have control over them. They're just provided as data. On the other hand, a composable can manage and create state. And then it passes that state along with, potentially, some data that it received down to other composable as parameters. Now, because it's managing that state-- Adam talked about this yesterday-- if you want to make a change to that state, you can allow your children composables to signal that change up towards you via callbacks. And finally, I want to talk about something called Recomposition. And this is basically our way of saying that any Composable function has this special ability to get re-invoked at any time. And so what this means is that, if you have this very large Composable hierarchy-- what happens is, when parts of your hierarchy change, you don't want to have to reinvent the entire hierarchy. And so Composable functions are sort of restartable in this way. And you can actually leverage this to do some pretty powerful things. So here's a Bind function that is maybe something you would see today in Android development. So we have a live data that we want to subscribe our view to. And so to do that, we end up calling the Observe method with a lifecycle owner. And then we pass in this lambda. And that lambda is going to get called every single time the live data updates. And when that happens, we might want to go and update our views. With Compose, we can actually kind of invert this relationship. So in Compose, we would have a similar Messages Composable. And it would receive a live data. And here, we call Compose's Observe method. And Observe does two things here. First, what it does is it unwraps that live data and returns the current value as its return value. And that means you can use it in the surrounding body of the function. But it also does something else. It implicitly-- well, it subscribes that live data to the Composable that it's being unwrapped in. And so that means that, instead of providing a lambda, you just now know that this Composable function will recompose every time live data changes. Looking at a simpler example of this, let's imagine that we have a simple counter composable. And so here we introduce a piece of state, which is our count. And State is as a function in Compose that returns an instance of this state class. And the state class is annotated with @Model. And what @Model does is it means that every property of that class-- now the reads and writes to that property are observable. And so what Compose does is, when you're executing your Composable function, if you read one of these model instances, Compose will automatically subscribe the surrounding scope to writes to that model. So that means that this example is self-contained. We have a counter that will get re-composed every time the value of that model is changed. OK, so we just talked about a lot of capabilities that Composable functions have. Let's start talking about how it's actually implemented. Before we do that, I'm going to get some water. OK-- small disclaimer-- everything I'm about to say is an implementation detail. And it's subject to change. In fact, it's very likely to change. But it's fun to talk about. But the important thing that I want to say is that understanding this is not required to use Compose. What I'm trying to do is to satisfy your intellectual curiosity here. And also, if you really want to dive into this and understand what's happening, this is a good primer. So we see this @Composable annotation in a lot of slides. What is it actually doing? I want to make an important point here, which is that Compose is not an annotation processor. How Compose works is through a Kotlin compiler plugin. And we work in the tight checking phase and in the code generation phase of Kotlin. So there's no annotation processing happening. The annotation here is actually more closely related to a language keyword. So I'm going to describe it in terms of an analogy, which is the "suspend" keyword. Kotlin's suspend keyword operates on function types. This means that you can have a function declaration that's a suspend. We can have a lambda. We can have a type. Compose works in the same way. We can alter function types. And the important point here is that, when you annotate a function type with that composable, you're changing that type. So the same function type without the annotation is not compatible with the type with the annotation. It's a different type. Additionally, suspend requires a calling context. This means that you can only call Suspend functions inside of another Suspend function. Composable works the exact same way. And this is because there's a calling context object that we need to thread through all of the invocations. And so I'm going to talk about what that object is. What is this calling context thing that we're passing around? And why do we need to do it? Well, the implementation of this object actually has some data structures in it that are very closely related to an existing data structure called the "gap buffer." Most of you probably aren't familiar with gap buffers. But if you work with text editors, you might know them. They're commonly used there. So to describe what a gap buffer is-- a gap buffer really implements a list. It's a collection interface. And it has a current index or cursor. And the way we implement this is with a flat array in memory. And so that flat array is necessarily larger than the collection of data that it represents. And so the space in that array that's unused we refer to as the gap. Now, as we execute our Composable hierarchy, we can appeal to this data structure. And we can insert things into it. And so you can think of the cursor as your current point of execution in your hierarchy. And so as we go through execution, we can insert items, insert another one, insert items. So now, let's imagine that we're done executing the hierarchy. At some point, we're going to go and we're going to re-compose something. And so we're going to reset the cursor to the top. And then we're going to go through execution again. And at this point, we're able to do a few things. We can look at the data that's there. And we can do nothing, if we decide. We can update the value. Or we can decide that the structure of the UI is changed. And then we want to make an insert. So this is the important thing. At this point, what we do is we move the gap to the current position. And now, we're able to make inserts at that point. So we keep going, keep making inserts. Now, the important thing to understand about this data structure is that all of the operations that we just talked about-- get, move, insert, delete-- all of those are constant time operations, except for moving the gap. Moving the gap is the expensive thing. So the reason we chose this data structure is because we're making a bet. The bet is that UIs, on average, don't actually change structure very much. When we have dynamic UIs, they change in terms of the values that are there. But they don't actually change in structure. And when they do, they typically change in big chunks. And so doing this gap move at that time is a good trade-off. OK-- so let's look at this example. We have the counter here. And this is the code that we would write. This is the example from earlier. Well, let's see what the compiler does. So when we see this Composable annotation, what we do is we actually insert additional parameters into this function. And so we pass in this Composer object through. And that Composer object is what kind of contains this gap buffer thing. You also might hear me refer to it as a slot table. Just think of it as the same thing. And so we also insert some calls in the body of this Composable. So we're going to call this composer.start method. And we're going to pass it in this key. And I'm going to talk about that in a second. Another thing that we're doing is we're passing that Composer object into all of the composable invocations that are in the body of this function. So we're threading it through. And we have these keys here. So these are these arbitrary looking integers. But the way to think about this correctly is that this represents, like, a hash of the source position that this call site represents. So this is sort of unique to each call site. So when we go through the execution of this composable, we go through and we call Start. And Start inserts a group object into the slot table. We go through. We call State. State inserts its own group object. And then the value that state returns is a state instance. That also stores that into the slot table. Then we move onto Button. Button is going to store a group as well. And then it's going to store each of its parameters. And Button might have this arbitrary implementation. We don't really know. And it's going to also use the slot table during that time. And when it's done, we're going to then call composer.end. And so you can see here that this data structure is holding all of these objects from this whole composition. And it's sort of the entire tree in execution order. It's like a depth first traversal of the tree. Now, all of those group objects that we just saw-- what are they there for? They're taking up a lot of space, right? So actually, those group objects are really important to manage the moves and the inserts that might happen with the dynamic UI. But we're a compiler. So we actually know what code looks like that changes the structure of your UI. So we can conditionally insert those groups. And most of the time, we find that we don't actually need them. So we don't actually have to insert that many groups into the slot table. To show an example of a case where we do, let's look at some conditional logic here. So here's a Composable. It has this getData function that returns some result and renders a loading composable in one case and a header and a body in another case. So here, we see that we're inserting separate keys for the first branch of the if statement and the second branch. And when we go through and execute it-- let's say, the first time this runs, the result is null. And so then we go and we run the loading screen. Now the second time we run it, let's pretend that feed item is the result here. So it's not a null. And at this point, we're going to go into the second branch of the if statement. And so this is where the interesting thing happens. At this point, we call composer.start. And it has a group with key 456. And it sees that the group in the slot table of 123 doesn't match. So now it knows that the UI has changed in structure. So what we do is we move the gap to the current cursor position. And then we extend the gap across the UI that was there before. So we kind of get rid of it. And now we insert the new UI, the header and the body. And so one way to look at this is the overhead of the if statement, in this case, was a single slot entry in the slot table. And it was this group. And by just inserting this single group, what this does is this allows us to have arbitrary control flow in our UI and allows us to manage it and appeal to this cache-like data structure, while we move through the execution of the UI. And so this concept is something that we call Positional Memoization. And Positional Memoization is a new thing. But this is the concept that Compose is built from, from the ground up. And I want to talk about what this means. So memoization is kind of a fancy sounding word. Normally, we have global memoization. And what memoization means is that we are caching the result of a function based on the inputs of that function. So an example of positional memoization here might be-- we have this computation that we're doing instead of a Composable function. We're taking in some string items and a query. And we're performing some sort of a filter operation on it. We can wrap this calculation in a call to memo. And we can pass in items and query to that call. And so memo is something that knows how to appeal to the slot table. And what it does is it looks at items. And there's nothing there. This is the first time we're running it. So all it does is just store it. And we look at query. We store that. And then we run the calculation. And we store the result. And then we pass it back. So that was fine. But the second time we execute it, that's when the interesting thing happens. So when we execute it again, memo goes and looks at the new values that are being passed in and compares them with the old values. And if neither of them have changed, then we can skip the calculation and just return the previous result. And so that's positional memoization. But the interesting thing here is that it was really cheap. We only had to store one previous invocation. And this calculation could happen all over your UI. And you're storing it positionally. So it only stores it for that location. And this is the signature of the Memo function. Memo here can take any number of inputs and then some sort of calculation function. But there is an interesting degenerate case here, which is when there are zero inputs. One of the things we can do is we can deliberately misuse this API. We can memoize an intentionally impure calculation, like, say, math.random. And if you were doing this with global memoization, this would make no sense. But with positional memoization, it ends up taking a new semantic. So here we have math.random, which is memoized. And we store in this value x. And for every time we use App in our composable hierarchy, there will be a new math.random value that's returned there. But every single time that composable re-composes, it will be the same math.random return value. So what this gives rise to is a persistence. And that persistence ends up giving rise to state. And this is what the State function actually is. State is just a call to memo around the State constructor. And so what that means is that you'll get the same instance the state across every invocation of it. And that's what we want. So let's move into talking about the way that we store the parameters to Composable functions. So here, we have a Google Composable, which takes in a number. This is kind of a silly example. But we're calling an address composable. And we're just rendering an address here. So we're calling a few text nodes underneath it. When we look at how this data ends up getting stored in the slot table, we end up seeing some redundancies. So the Mountain View in California that we added in the address invocation ends up getting stored again in the underlying text invocations. So it turns out that we can actually get rid of this redundancy by adding another parameter to Composable functions at the compiler level. So here we have the static parameter. And this is a bit field that indicates whether or not a given parameter is known by the runtime to not change. And if it's known to not change, then there's no need for us to store it. So we can see here in this Google example, we can pass a bit field that says, none of these parameters are ever changing. And then in Address, we can do the same thing and pass it into text. Now all of this bit wise logic is hard to read. It's confusing. And there's no intention of you understanding this. Compilers are good at this. Humans aren't. And this is exactly the kind of thing you want a compiler doing. So let's go back to our top level example here. And we can see this redundant information that we no longer need to store. But additionally, there are all these values that are constant here. These are static values. It turns out, we don't need to store them either. And so this entire hierarchy is actually purely determined by this one number parameter. And that ends up being the only value that we need to store. But we can actually go further. So we can generate code that understands that number is the only thing that's going to change. And so what we can say is, well, if number hasn't changed, don't bother doing anything else. Just skip this invocation. And the composer knows exactly how far to fast forward the execution to resume exactly where it needs to. So the final concept I want to talk about is explaining how this recomposition happens that we talked about earlier. So going back to this counter, the generated code that we would create for this counter has a composer start and end, like we've seen. And what I mentioned earlier is that, whenever we execute Counter, the runtime understands that when I call count that value, I'm reading the property of an app model instance. And so at runtime, what happens is whenever we call end, we optionally return a value. And then we can call an update scope method on that value, basically, with a lambda that tells the runtime, here's how to restart this Composable if you need to. And so this is sort of equivalent to that lambda that that live data would be receiving otherwise. And the reason that this question mark is here-- the reason that this is nullable-- is because, if we don't actually read any model objects during the execution of Counter, then there's absolutely no reason to teach the runtime how to update this, because we know it never will update. So some closing thoughts-- I want to emphasize again that this is still really, really early and, more importantly, that this isn't production ready. And we really mean that. This is a really big undertaking. And we're really still exploring a lot of these options. And a lot of the things I just talked about are pretty new explorations on our side. And so I think it's pretty interesting. But just know that a lot of these things are still happening. And we think there are some really, really powerful ways that we can really gain some performance in a world where we have Composable functions. But we're still exploring that. The other thing I want to point out is that everything we just talked about that's being done by the compiler is required for correctness. And there's an interesting oversight where code that uses @Composable and things like that compiles just fine with the old Kotlin compiler. There are some blogs out on the internet that say, hey, you don't need Android Studio for-- and all that. They're wrong. [LAUGHTER] So what I ask is that you all be careful. This was an oversight on our part, and we're exploring how to make this less of a foot gun. But really, your code is not correct unless you're using this compiler, basically, a new version of Android Studio 4 or higher. So just be very careful with that. And then finally, I want to give a shout out to the Kotlin Lang slack channel. There's a Compose channel in that community. It's very active. A lot of folks from the Compose team are on there, including myself. And if you do want to get involved and learn more about these things or even help out, that's a great place to start. Also, another shout out-- we do a lot of UX studies around Compose and APIs we're choosing. And we're really looking for more volunteers to help us out. So upstairs at the Compose sandbox, there's a way to sign up if you're interested. And that's it. Thank you. [APPLAUSE] [MUSIC PLAYING]
Info
Channel: Android Developers
Views: 54,035
Rating: undefined out of 5
Keywords: type: Conference Talk (Full production);, pr_pr: Android, purpose: Educate
Id: Q9MtlmmN4Q0
Channel Id: undefined
Length: 36min 15sec (2175 seconds)
Published: Thu Oct 24 2019
Related Videos
Note
Please note that this website is currently a work in progress! Lots of interesting data and statistics to come.